chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly>
This commit is contained in:
@@ -1,15 +1,28 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill("package.json")
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette, clickListItem } from "../actions"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
await page.locator(promptSelector).click()
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
const input = dialog.getByRole("textbox").first()
|
||||||
await input.fill(file)
|
await input.fill("package.json")
|
||||||
|
|
||||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
const code = page.locator('[data-component="code"]').first()
|
||||||
await expect(code).toBeVisible()
|
await expect(code).toBeVisible()
|
||||||
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
|
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
|
await expect(prompt).toBeEditable()
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(text)
|
await expect(prompt).toBeFocused()
|
||||||
await page.keyboard.press("Enter")
|
await prompt.fill(text)
|
||||||
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return sessionID
|
return sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,12 @@ import {
|
|||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
clickMenuItem,
|
clickMenuItem,
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
openProjectMenu,
|
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import {
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
inlineInputSelector,
|
import { createSdk, dirSlug } from "../utils"
|
||||||
projectSwitchSelector,
|
|
||||||
projectWorkspacesToggleSelector,
|
|
||||||
workspaceItemSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
@@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(async () => {
|
||||||
async () => {
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
await expect(nonGitButton).toBeVisible()
|
|
||||||
await nonGitButton.click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
|
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, nonGitSlug)
|
const activeDir = base64Decode(slugFromUrl(page.url()))
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
await openSidebar(page)
|
||||||
await expect(toggle).toBeDisabled()
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
const trigger = page.locator('[data-action="project-menu"]').first()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
const hasMenu = await trigger
|
||||||
},
|
.isVisible()
|
||||||
{ extra: [nonGit] },
|
.then((x) => x)
|
||||||
)
|
.catch(() => false)
|
||||||
|
if (!hasMenu) return
|
||||||
|
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
|
||||||
|
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
||||||
|
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(toggle).toBeDisabled()
|
||||||
|
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(nonGit)
|
await cleanupTestProject(nonGit)
|
||||||
}
|
}
|
||||||
@@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await withProject(async (project) => {
|
||||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
const sdk = createSdk(project.directory)
|
||||||
|
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,40 +1,95 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { withSession } from "../actions"
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
function contextButton(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('[data-component="button"]')
|
||||||
|
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "seed context",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const messages = await input.sdk.session
|
||||||
|
.messages({ sessionID: input.sessionID, limit: 1 })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
const title = `e2e smoke context ${Date.now()}`
|
const title = `e2e smoke context ${Date.now()}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
await sdk.session.promptAsync({
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
sessionID: session.id,
|
|
||||||
noReply: true,
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "seed context",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const contextButton = page
|
const trigger = contextButton(page)
|
||||||
.locator('[data-component="button"]')
|
await expect(trigger).toBeVisible()
|
||||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
await trigger.click()
|
||||||
.first()
|
|
||||||
|
|
||||||
await expect(contextButton).toBeVisible()
|
|
||||||
await contextButton.click()
|
|
||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
const context = tabs.getByRole("tab", { name: "Context" })
|
||||||
|
await expect(context).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Close tab" }).first().click()
|
||||||
|
await expect(context).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
|
await page.getByRole("button", { name: "Open file" }).first().click()
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
)
|
)
|
||||||
|
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
|
|
||||||
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
|
|
||||||
await expect(reply).toBeVisible({ timeout: 90_000 })
|
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||||
|
|||||||
@@ -10,21 +10,26 @@ async function seedConversation(input: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
token: string
|
token: string
|
||||||
}) {
|
}) {
|
||||||
|
const messages = async () =>
|
||||||
|
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
|
||||||
|
const seeded = await messages()
|
||||||
|
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
|
||||||
|
|
||||||
const prompt = input.page.locator(promptSelector)
|
const prompt = input.page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
await prompt.click()
|
await input.sdk.session.promptAsync({
|
||||||
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
|
sessionID: input.sessionID,
|
||||||
await input.page.keyboard.press("Enter")
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: input.token }],
|
||||||
|
})
|
||||||
|
|
||||||
let userMessageID: string | undefined
|
let userMessageID: string | undefined
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const messages = await input.sdk.session
|
const users = (await messages()).filter(
|
||||||
.messages({ sessionID: input.sessionID, limit: 50 })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
const users = messages.filter(
|
|
||||||
(m) =>
|
(m) =>
|
||||||
|
!userIDs.has(m.info.id) &&
|
||||||
m.info.role === "user" &&
|
m.info.role === "user" &&
|
||||||
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
||||||
)
|
)
|
||||||
@@ -33,21 +38,14 @@ async function seedConversation(input: {
|
|||||||
const user = users[users.length - 1]
|
const user = users[users.length - 1]
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
userMessageID = user.info.id
|
userMessageID = user.info.id
|
||||||
|
return true
|
||||||
const assistantText = messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return assistantText.includes(input.token)
|
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000, intervals: [250, 500, 1_000] },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const newTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(newTitle)
|
await expect(input).toBeFocused()
|
||||||
|
await input.fill(renamedTitle)
|
||||||
|
await expect(input).toHaveValue(renamedTitle)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.title
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
await seedMessage(sdk, session.id)
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
|
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||||
|
await publish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.not.toBeUndefined()
|
.not.toBeUndefined()
|
||||||
|
|
||||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const sharedPopover = await openSharePopover(page)
|
|
||||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||||
await unpublish.click()
|
await unpublish.click()
|
||||||
|
|
||||||
|
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
)
|
)
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
const unshared = await openSharePopover(page)
|
||||||
|
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||||
const unsharedPopover = await openSharePopover(page)
|
|
||||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
|
import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
|
||||||
import { Router, Route, Navigate } from "@solidjs/router"
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Suspense, JSX } from "solid-js"
|
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
|
const HomeRoute = () => (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Home />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionRoute = () => (
|
||||||
|
<SessionProviders>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Session />
|
||||||
|
</Suspense>
|
||||||
|
</SessionProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionIndexRoute = () => <Navigate href="session" />
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
||||||
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppShellProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<PermissionProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<ModelsProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
|
</CommandProvider>
|
||||||
|
</ModelsProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</PermissionProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionProviders(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<TerminalProvider>
|
||||||
|
<FileProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<CommentsProvider>{props.children}</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
|
</TerminalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
|
return (
|
||||||
|
<AppShellProviders>
|
||||||
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</AppShellProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
|
||||||
|
if (platform.platform !== "web") return
|
||||||
|
const result = platform.getDefaultServerUrl?.()
|
||||||
|
if (result instanceof Promise) return
|
||||||
|
if (!result) return
|
||||||
|
return normalizeServerUrl(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDefaultServerUrl = (props: {
|
||||||
|
defaultUrl?: string
|
||||||
|
storedDefaultServerUrl?: string
|
||||||
|
hostname: string
|
||||||
|
origin: string
|
||||||
|
isDev: boolean
|
||||||
|
devHost?: string
|
||||||
|
devPort?: string
|
||||||
|
}) => {
|
||||||
|
if (props.defaultUrl) return props.defaultUrl
|
||||||
|
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
|
||||||
|
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||||
|
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
|
||||||
|
return props.origin
|
||||||
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
@@ -77,89 +156,35 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
if (!server.url) return null
|
||||||
<Show when={server.url} keyed>
|
return props.children
|
||||||
{props.children}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
|
||||||
const stored = (() => {
|
const defaultServerUrl = resolveDefaultServerUrl({
|
||||||
if (platform.platform !== "web") return
|
defaultUrl: props.defaultUrl,
|
||||||
const result = platform.getDefaultServerUrl?.()
|
storedDefaultServerUrl,
|
||||||
if (result instanceof Promise) return
|
hostname: location.hostname,
|
||||||
if (!result) return
|
origin: window.location.origin,
|
||||||
return normalizeServerUrl(result)
|
isDev: import.meta.env.DEV,
|
||||||
})()
|
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
|
||||||
|
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
|
||||||
const defaultServerUrl = () => {
|
})
|
||||||
if (props.defaultUrl) return props.defaultUrl
|
|
||||||
if (stored) return stored
|
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
|
||||||
if (import.meta.env.DEV)
|
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
return window.location.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
|
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(routerProps) => (
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
<SettingsProvider>
|
|
||||||
<PermissionProvider>
|
|
||||||
<LayoutProvider>
|
|
||||||
<NotificationProvider>
|
|
||||||
<ModelsProvider>
|
|
||||||
<CommandProvider>
|
|
||||||
<HighlightsProvider>
|
|
||||||
<Layout>
|
|
||||||
{props.children}
|
|
||||||
{routerProps.children}
|
|
||||||
</Layout>
|
|
||||||
</HighlightsProvider>
|
|
||||||
</CommandProvider>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Route
|
<Route path="/" component={HomeRoute} />
|
||||||
path="/"
|
|
||||||
component={() => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
<Route
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
path="/session/:id?"
|
|
||||||
component={(p) => (
|
|
||||||
<Show when={p.params.id ?? "new"}>
|
|
||||||
<TerminalProvider>
|
|
||||||
<FileProvider>
|
|
||||||
<PromptProvider>
|
|
||||||
<CommentsProvider>
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</CommentsProvider>
|
|
||||||
</PromptProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</TerminalProvider>
|
|
||||||
</Show>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "method.select"; index: number }
|
||||||
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.pending" }
|
||||||
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
if (action.type === "method.select") {
|
||||||
|
draft.methodIndex = action.index
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "method.reset") {
|
||||||
|
draft.methodIndex = undefined
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.pending") {
|
||||||
|
draft.state = "pending"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.complete") {
|
||||||
|
draft.state = "complete"
|
||||||
|
draft.authorization = action.authorization
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.state = "error"
|
||||||
|
draft.error = action.error
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
@@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = methods()[index]
|
const method = methods()[index]
|
||||||
setStore(
|
dispatch({ type: "method.select", index })
|
||||||
produce((draft) => {
|
|
||||||
draft.methodIndex = index
|
|
||||||
draft.authorization = undefined
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
setStore("state", "pending")
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
.authorize(
|
.authorize(
|
||||||
@@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
}, delay)
|
}, delay)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("state", "complete")
|
dispatch({ type: "auth.complete", authorization: x.data! })
|
||||||
setStore("authorization", x.data!)
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive.value) return
|
if (!alive.value) return
|
||||||
setStore("state", "error")
|
dispatch({ type: "auth.error", error: String(e) })
|
||||||
setStore("error", String(e))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function complete() {
|
async function complete() {
|
||||||
@@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
setStore("authorization", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
setStore("methodIndex", undefined)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.methodIndex) {
|
if (store.methodIndex !== undefined) {
|
||||||
setStore("methodIndex", undefined)
|
dispatch({ type: "method.reset" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MethodSelection() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
ref={(ref) => {
|
||||||
|
listRef = ref
|
||||||
|
}}
|
||||||
|
items={methods}
|
||||||
|
key={(m) => m?.label}
|
||||||
|
onSelect={async (selected, index) => {
|
||||||
|
if (!selected) return
|
||||||
|
selectMethod(index)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(i) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2">
|
||||||
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
|
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
|
</div>
|
||||||
|
<span>{methodLabel(i)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiAuthView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: "",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const apiKey = formData.get("apiKey") as string
|
||||||
|
|
||||||
|
if (!apiKey?.trim()) {
|
||||||
|
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStore("error", undefined)
|
||||||
|
await globalSDK.client.auth.set({
|
||||||
|
providerID: props.provider,
|
||||||
|
auth: {
|
||||||
|
type: "api",
|
||||||
|
key: apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<Switch>
|
||||||
|
<Match when={provider().id === "opencode"}>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
|
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
|
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
|
</Link>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
|
name="apiKey"
|
||||||
|
value={formStore.value}
|
||||||
|
onChange={(v) => setFormStore("value", v)}
|
||||||
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
|
error={formStore.error}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
|
{language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthCodeView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: "",
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (store.authorization?.method === "code" && store.authorization?.url) {
|
||||||
|
platform.openLink(store.authorization.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const form = e.currentTarget as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
const code = formData.get("code") as string
|
||||||
|
|
||||||
|
if (!code?.trim()) {
|
||||||
|
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStore("error", undefined)
|
||||||
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
.callback({
|
||||||
|
providerID: props.provider,
|
||||||
|
method: store.methodIndex,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
if (result.ok) {
|
||||||
|
await complete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
||||||
|
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
|
name="code"
|
||||||
|
value={formStore.value}
|
||||||
|
onChange={(v) => setFormStore("value", v)}
|
||||||
|
validationState={formStore.error ? "invalid" : undefined}
|
||||||
|
error={formStore.error}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
|
{language.t("common.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthAutoView() {
|
||||||
|
const code = createMemo(() => {
|
||||||
|
const instructions = store.authorization?.instructions
|
||||||
|
if (instructions?.includes(":")) {
|
||||||
|
return instructions.split(":")[1]?.trim()
|
||||||
|
}
|
||||||
|
return instructions
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (store.authorization?.url) {
|
||||||
|
platform.openLink(store.authorization.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await globalSDK.client.provider.oauth
|
||||||
|
.callback({
|
||||||
|
providerID: props.provider,
|
||||||
|
method: store.methodIndex,
|
||||||
|
})
|
||||||
|
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
|
||||||
|
if (!alive.value) return
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
||||||
|
dispatch({ type: "auth.error", error: message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await complete()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
||||||
|
class="font-mono"
|
||||||
|
value={code()}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<div class="text-14-regular text-text-base flex items-center gap-4">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.waiting")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={
|
title={
|
||||||
@@ -188,267 +441,42 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<Switch>
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Switch>
|
||||||
<div class="text-14-regular text-text-base">
|
<Match when={store.methodIndex === undefined}>
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
<MethodSelection />
|
||||||
</div>
|
</Match>
|
||||||
<div class="">
|
<Match when={store.state === "pending"}>
|
||||||
<List
|
<div class="text-14-regular text-text-base">
|
||||||
ref={(ref) => {
|
<div class="flex items-center gap-x-2">
|
||||||
listRef = ref
|
<Spinner />
|
||||||
}}
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
items={methods}
|
|
||||||
key={(m) => m?.label}
|
|
||||||
onSelect={async (method, index) => {
|
|
||||||
if (!method) return
|
|
||||||
selectMethod(index)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="w-full flex items-center gap-x-2">
|
|
||||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
|
||||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
|
||||||
</div>
|
|
||||||
<span>{methodLabel(i)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "pending"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Spinner />
|
|
||||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={store.state === "error"}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
<div class="flex items-center gap-x-2">
|
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
|
||||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={method()?.type === "api"}>
|
|
||||||
{iife(() => {
|
|
||||||
const [formStore, setFormStore] = createStore({
|
|
||||||
value: "",
|
|
||||||
error: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const form = e.currentTarget as HTMLFormElement
|
|
||||||
const formData = new FormData(form)
|
|
||||||
const apiKey = formData.get("apiKey") as string
|
|
||||||
|
|
||||||
if (!apiKey?.trim()) {
|
|
||||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
|
||||||
await globalSDK.client.auth.set({
|
|
||||||
providerID: props.provider,
|
|
||||||
auth: {
|
|
||||||
type: "api",
|
|
||||||
key: apiKey,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<Switch>
|
|
||||||
<Match when={provider().id === "opencode"}>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line1")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.line2")}
|
|
||||||
</div>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
|
||||||
<Link href="https://opencode.ai/zen" tabIndex={-1}>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
||||||
name="apiKey"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={setFormStore.bind(null, "value")}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
</Match>
|
||||||
</Match>
|
<Match when={store.state === "error"}>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<div class="text-14-regular text-text-base">
|
||||||
<Switch>
|
<div class="flex items-center gap-x-2">
|
||||||
<Match when={store.authorization?.method === "code"}>
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
{iife(() => {
|
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||||
const [formStore, setFormStore] = createStore({
|
</div>
|
||||||
value: "",
|
</div>
|
||||||
error: undefined as string | undefined,
|
</Match>
|
||||||
})
|
<Match when={method()?.type === "api"}>
|
||||||
|
<ApiAuthView />
|
||||||
onMount(() => {
|
</Match>
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
<Match when={method()?.type === "oauth"}>
|
||||||
platform.openLink(store.authorization.url)
|
<Switch>
|
||||||
}
|
<Match when={store.authorization?.method === "code"}>
|
||||||
})
|
<OAuthCodeView />
|
||||||
|
</Match>
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
<Match when={store.authorization?.method === "auto"}>
|
||||||
e.preventDefault()
|
<OAuthAutoView />
|
||||||
|
</Match>
|
||||||
const form = e.currentTarget as HTMLFormElement
|
</Switch>
|
||||||
const formData = new FormData(form)
|
</Match>
|
||||||
const code = formData.get("code") as string
|
</Switch>
|
||||||
|
</div>
|
||||||
if (!code?.trim()) {
|
|
||||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
|
||||||
.callback({
|
|
||||||
providerID: props.provider,
|
|
||||||
method: store.methodIndex,
|
|
||||||
code,
|
|
||||||
})
|
|
||||||
.then((value) =>
|
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
if (result.ok) {
|
|
||||||
await complete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
||||||
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
|
||||||
<Link href={store.authorization!.url}>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
||||||
name="code"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={setFormStore.bind(null, "value")}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.submit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Match>
|
|
||||||
<Match when={store.authorization?.method === "auto"}>
|
|
||||||
{iife(() => {
|
|
||||||
const code = createMemo(() => {
|
|
||||||
const instructions = store.authorization?.instructions
|
|
||||||
if (instructions?.includes(":")) {
|
|
||||||
return instructions?.split(":")[1]?.trim()
|
|
||||||
}
|
|
||||||
return instructions
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
void (async () => {
|
|
||||||
if (store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
|
||||||
.callback({
|
|
||||||
providerID: props.provider,
|
|
||||||
method: store.methodIndex,
|
|
||||||
})
|
|
||||||
.then((value) =>
|
|
||||||
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
|
|
||||||
)
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
|
|
||||||
if (!alive.value) return
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
const message = result.error instanceof Error ? result.error.message : String(result.error)
|
|
||||||
setStore("state", "error")
|
|
||||||
setStore("error", message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await complete()
|
|
||||||
})()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
|
||||||
<Link href={store.authorization!.url}>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
|
||||||
</Link>
|
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.connect.oauth.auto.confirmationCode")}
|
|
||||||
class="font-mono"
|
|
||||||
value={code()}
|
|
||||||
readOnly
|
|
||||||
copyable
|
|
||||||
/>
|
|
||||||
<div class="text-14-regular text-text-base flex items-center gap-4">
|
|
||||||
<Spinner />
|
|
||||||
<span>{language.t("provider.connect.status.waiting")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||||
|
|
||||||
|
type ModelRow = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderRow = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
saving: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormErrors = {
|
||||||
|
providerID: string | undefined
|
||||||
|
name: string | undefined
|
||||||
|
baseURL: string | undefined
|
||||||
|
models: Array<{ id?: string; name?: string }>
|
||||||
|
headers: Array<{ key?: string; value?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const modelErrors = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const modelIdError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: modelIdError, name: modelNameError }
|
||||||
|
})
|
||||||
|
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||||
|
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headerErrors = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const errors: FormErrors = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
models: modelErrors,
|
||||||
|
headers: headerErrors,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { errors }
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options,
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore({
|
const [form, setForm] = createStore<FormState>({
|
||||||
providerID: "",
|
providerID: "",
|
||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
saving: false,
|
saving: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [errors, setErrors] = createStore({
|
const [errors, setErrors] = createStore<FormErrors>({
|
||||||
providerID: undefined as string | undefined,
|
providerID: undefined,
|
||||||
name: undefined as string | undefined,
|
name: undefined,
|
||||||
baseURL: undefined as string | undefined,
|
baseURL: undefined,
|
||||||
models: [{} as { id?: string; name?: string }],
|
models: [{}],
|
||||||
headers: [{} as { key?: string; value?: string }],
|
headers: [{}],
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm(
|
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||||
"models",
|
setErrors("models", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ id: "", name: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm(
|
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||||
"models",
|
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"models",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm(
|
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||||
"headers",
|
setErrors("headers", (v) => [...v, {}])
|
||||||
produce((draft) => {
|
|
||||||
draft.push({ key: "", value: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.push({})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm(
|
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
"headers",
|
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setErrors(
|
|
||||||
"headers",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const providerID = form.providerID.trim()
|
const output = validateCustomProvider({
|
||||||
const name = form.name.trim()
|
form,
|
||||||
const baseURL = form.baseURL.trim()
|
t: language.t,
|
||||||
const apiKey = form.apiKey.trim()
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? language.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? language.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? language.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? language.t("provider.custom.error.baseURL.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
|
|
||||||
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
|
|
||||||
const existsError = idError
|
|
||||||
? undefined
|
|
||||||
: existingProvider && !disabled
|
|
||||||
? language.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
})
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
setErrors(output.errors)
|
||||||
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
return output.result
|
||||||
|
|
||||||
const seenHeaders = new Set<string>()
|
|
||||||
const headerErrors = form.headers.map((h) => {
|
|
||||||
const key = h.key.trim()
|
|
||||||
const value = h.value.trim()
|
|
||||||
|
|
||||||
if (!key && !value) return {}
|
|
||||||
const keyError = !key
|
|
||||||
? language.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? language.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? language.t("provider.custom.error.required") : undefined
|
|
||||||
return { key: keyError, value: valueError }
|
|
||||||
})
|
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
|
||||||
const headers = Object.fromEntries(
|
|
||||||
form.headers
|
|
||||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
|
||||||
.filter((h) => !!h.key && !!h.value)
|
|
||||||
.map((h) => [h.key, h.value]),
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrors(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.providerID = idError ?? existsError
|
|
||||||
draft.name = nameError
|
|
||||||
draft.baseURL = urlError
|
|
||||||
draft.models = modelErrors
|
|
||||||
draft.headers = headerErrors
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
baseURL,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
name,
|
|
||||||
key,
|
|
||||||
config: {
|
|
||||||
npm: OPENAI_COMPATIBLE,
|
|
||||||
name,
|
|
||||||
...(env ? { env: [env] } : {}),
|
|
||||||
options,
|
|
||||||
models,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const save = async (e: SubmitEvent) => {
|
||||||
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={setForm.bind(null, "providerID")}
|
onChange={(v) => setForm("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={errors.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={errors.providerID}
|
||||||
/>
|
/>
|
||||||
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={setForm.bind(null, "name")}
|
onChange={(v) => setForm("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={errors.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={setForm.bind(null, "baseURL")}
|
onChange={(v) => setForm("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={errors.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={errors.baseURL}
|
||||||
/>
|
/>
|
||||||
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={setForm.bind(null, "apiKey")}
|
onChange={(v) => setForm("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
function handleFileSelect(file: File) {
|
||||||
if (!file.type.startsWith("image/")) return
|
if (!file.type.startsWith("image/")) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
setStore("saving", true)
|
await Promise.resolve()
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
.then(async () => {
|
||||||
const start = store.startup.trim()
|
setStore("saving", true)
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
await globalSDK.client.project.update({
|
await globalSDK.client.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
commands: { start },
|
commands: { start },
|
||||||
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("saving", false)
|
||||||
})
|
})
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
setStore("saving", false)
|
|
||||||
dialog.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
if (store.iconUrl && store.iconHover) {
|
if (store.iconUrl && store.iconHover) {
|
||||||
clearIcon()
|
clearIcon()
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("icon-upload")?.click()
|
iconInput?.click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
<input
|
||||||
|
id="icon-upload"
|
||||||
|
ref={(el) => {
|
||||||
|
iconInput = el
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
|
|||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
|
||||||
dialog.close()
|
sdk.client.session
|
||||||
|
.fork({ sessionID, messageID: item.id })
|
||||||
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) return
|
if (!forked.data) {
|
||||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
showToast({ title: language.t("common.requestFailed") })
|
||||||
requestAnimationFrame(() => {
|
return
|
||||||
prompt.set(restored)
|
}
|
||||||
|
dialog.close()
|
||||||
|
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
prompt.set(restored)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
})
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
dialog.show(() => <DialogSelectProvider />)
|
||||||
}
|
}
|
||||||
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -37,19 +38,18 @@ export const DialogManageModels: Component = () => {
|
|||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.name}
|
||||||
sortGroupsBy={(a, b) => {
|
sortGroupsBy={(a, b) => {
|
||||||
const aProvider = a.items[0].provider.id
|
const aRank = providerRank(a.items[0].provider.id)
|
||||||
const bProvider = b.items[0].provider.id
|
const bRank = providerRank(b.items[0].provider.id)
|
||||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
const aPopular = aRank >= 0
|
||||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
const bPopular = bRank >= 0
|
||||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
if (aPopular && !bPopular) return -1
|
||||||
|
if (!aPopular && bPopular) return 1
|
||||||
|
return aRank - bRank
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
const visible = local.model.visible({
|
const key = { modelID: x.id, providerID: x.provider.id }
|
||||||
modelID: x.id,
|
local.model.setVisibility(key, !local.model.visible(key))
|
||||||
providerID: x.provider.id,
|
|
||||||
})
|
|
||||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
@@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => {
|
|||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={
|
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
|
||||||
!!local.model.visible({
|
|
||||||
modelID: i.id,
|
|
||||||
providerID: i.provider.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusTrap: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
focusTrap?.focus()
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Refocus the trap when index changes to ensure escape always works
|
|
||||||
createEffect(() => {
|
|
||||||
index() // track index
|
|
||||||
focusTrap?.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
size="large"
|
size="large"
|
||||||
fit
|
fit
|
||||||
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Hidden element to capture initial focus and handle escape */}
|
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
|
||||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
|
||||||
<div class="flex flex-1 min-w-0 min-h-0">
|
|
||||||
{/* Left side - Text content */}
|
{/* Left side - Text content */}
|
||||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||||
{/* Top section - feature content (fixed position from top) */}
|
{/* Top section - feature content (fixed position from top) */}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
|
import type { ListRef } from "@opencode-ai/ui/list"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { ListRef } from "@opencode-ai/ui/list"
|
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -21,157 +21,131 @@ type Row = {
|
|||||||
search: string
|
search: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
function cleanInput(value: string) {
|
||||||
const sync = useGlobalSync()
|
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
||||||
const sdk = useGlobalSDK()
|
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
||||||
const dialog = useDialog()
|
}
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const [filter, setFilter] = createSignal("")
|
function normalizePath(input: string) {
|
||||||
|
const v = input.replaceAll("\\", "/")
|
||||||
|
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
||||||
|
return v.replace(/\/+/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
let list: ListRef | undefined
|
function normalizeDriveRoot(input: string) {
|
||||||
|
const v = normalizePath(input)
|
||||||
|
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
function trimTrailing(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
return v.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
const [fallbackPath] = createResource(
|
function joinPath(base: string | undefined, rel: string) {
|
||||||
() => (missingBase() ? true : undefined),
|
const b = trimTrailing(base ?? "")
|
||||||
async () => {
|
const r = trimTrailing(rel).replace(/^\/+/, "")
|
||||||
return sdk.client.path
|
if (!b) return r
|
||||||
.get()
|
if (!r) return b
|
||||||
.then((x) => x.data)
|
if (b.endsWith("/")) return b + r
|
||||||
.catch(() => undefined)
|
return b + "/" + r
|
||||||
},
|
}
|
||||||
{ initialValue: undefined },
|
|
||||||
)
|
|
||||||
|
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
function rootOf(input: string) {
|
||||||
|
const v = normalizeDriveRoot(input)
|
||||||
|
if (v.startsWith("//")) return "//"
|
||||||
|
if (v.startsWith("/")) return "/"
|
||||||
|
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const start = createMemo(
|
function parentOf(input: string) {
|
||||||
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
const v = trimTrailing(input)
|
||||||
)
|
if (v === "/") return v
|
||||||
|
if (v === "//") return v
|
||||||
|
if (/^[A-Za-z]:\/$/.test(v)) return v
|
||||||
|
|
||||||
|
const i = v.lastIndexOf("/")
|
||||||
|
if (i <= 0) return "/"
|
||||||
|
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
||||||
|
return v.slice(0, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeOf(input: string) {
|
||||||
|
const raw = normalizeDriveRoot(input.trim())
|
||||||
|
if (!raw) return "relative" as const
|
||||||
|
if (raw.startsWith("~")) return "tilde" as const
|
||||||
|
if (rootOf(raw)) return "absolute" as const
|
||||||
|
return "relative" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function tildeOf(absolute: string, home: string) {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
if (!home) return ""
|
||||||
|
|
||||||
|
const hn = trimTrailing(home)
|
||||||
|
const lc = full.toLowerCase()
|
||||||
|
const hc = hn.toLowerCase()
|
||||||
|
if (lc === hc) return "~"
|
||||||
|
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPath(path: string, input: string, home: string) {
|
||||||
|
const full = trimTrailing(path)
|
||||||
|
if (modeOf(input) === "absolute") return full
|
||||||
|
return tildeOf(full, home) || full
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(absolute: string, home: string): Row {
|
||||||
|
const full = trimTrailing(absolute)
|
||||||
|
const tilde = tildeOf(full, home)
|
||||||
|
const withSlash = (value: string) => {
|
||||||
|
if (!value) return ""
|
||||||
|
if (value.endsWith("/")) return value
|
||||||
|
return value + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = Array.from(
|
||||||
|
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||||
|
).join("\n")
|
||||||
|
return { absolute: full, search }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDirectorySearch(args: {
|
||||||
|
sdk: ReturnType<typeof useGlobalSDK>
|
||||||
|
start: () => string | undefined
|
||||||
|
home: () => string
|
||||||
|
}) {
|
||||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||||
|
let current = 0
|
||||||
|
|
||||||
const clean = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
|
const base = args.start()
|
||||||
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(input: string) {
|
|
||||||
const v = input.replaceAll("\\", "/")
|
|
||||||
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
|
|
||||||
return v.replace(/\/+/g, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDriveRoot(input: string) {
|
|
||||||
const v = normalize(input)
|
|
||||||
if (/^[A-Za-z]:$/.test(v)) return v + "/"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimTrailing(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
return v.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function join(base: string | undefined, rel: string) {
|
|
||||||
const b = trimTrailing(base ?? "")
|
|
||||||
const r = trimTrailing(rel).replace(/^\/+/, "")
|
|
||||||
if (!b) return r
|
|
||||||
if (!r) return b
|
|
||||||
if (b.endsWith("/")) return b + r
|
|
||||||
return b + "/" + r
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootOf(input: string) {
|
|
||||||
const v = normalizeDriveRoot(input)
|
|
||||||
if (v.startsWith("//")) return "//"
|
|
||||||
if (v.startsWith("/")) return "/"
|
|
||||||
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentOf(input: string) {
|
|
||||||
const v = trimTrailing(input)
|
|
||||||
if (v === "/") return v
|
|
||||||
if (v === "//") return v
|
|
||||||
if (/^[A-Za-z]:\/$/.test(v)) return v
|
|
||||||
|
|
||||||
const i = v.lastIndexOf("/")
|
|
||||||
if (i <= 0) return "/"
|
|
||||||
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
|
|
||||||
return v.slice(0, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeOf(input: string) {
|
|
||||||
const raw = normalizeDriveRoot(input.trim())
|
|
||||||
if (!raw) return "relative" as const
|
|
||||||
if (raw.startsWith("~")) return "tilde" as const
|
|
||||||
if (rootOf(raw)) return "absolute" as const
|
|
||||||
return "relative" as const
|
|
||||||
}
|
|
||||||
|
|
||||||
function display(path: string, input: string) {
|
|
||||||
const full = trimTrailing(path)
|
|
||||||
if (modeOf(input) === "absolute") return full
|
|
||||||
|
|
||||||
return tildeOf(full) || full
|
|
||||||
}
|
|
||||||
|
|
||||||
function tildeOf(absolute: string) {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
const h = home()
|
|
||||||
if (!h) return ""
|
|
||||||
|
|
||||||
const hn = trimTrailing(h)
|
|
||||||
const lc = full.toLowerCase()
|
|
||||||
const hc = hn.toLowerCase()
|
|
||||||
if (lc === hc) return "~"
|
|
||||||
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(absolute: string): Row {
|
|
||||||
const full = trimTrailing(absolute)
|
|
||||||
const tilde = tildeOf(full)
|
|
||||||
|
|
||||||
const withSlash = (value: string) => {
|
|
||||||
if (!value) return ""
|
|
||||||
if (value.endsWith("/")) return value
|
|
||||||
return value + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = Array.from(
|
|
||||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
|
||||||
).join("\n")
|
|
||||||
return { absolute: full, search }
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoped(value: string) {
|
|
||||||
const base = start()
|
|
||||||
if (!base) return
|
if (!base) return
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
if (!raw) return { directory: trimTrailing(base), path: "" }
|
if (!raw) return { directory: trimTrailing(base), path: "" }
|
||||||
|
|
||||||
const h = home()
|
const h = args.home()
|
||||||
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
|
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
|
||||||
|
|
||||||
const root = rootOf(raw)
|
const root = rootOf(raw)
|
||||||
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
|
||||||
return { directory: trimTrailing(base), path: raw }
|
return { directory: trimTrailing(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dirs(dir: string) {
|
const dirs = async (dir: string) => {
|
||||||
const key = trimTrailing(dir)
|
const key = trimTrailing(dir)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
|
|
||||||
const request = sdk.client.file
|
const request = args.sdk.client.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ directory: key, path: "" })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
async function match(dir: string, query: string, limit: number) {
|
const match = async (dir: string, query: string, limit: number) => {
|
||||||
const items = await dirs(dir)
|
const items = await dirs(dir)
|
||||||
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
if (!query) return items.slice(0, limit).map((x) => x.absolute)
|
||||||
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
const directories = async (filter: string) => {
|
return async (filter: string) => {
|
||||||
const value = clean(filter)
|
const token = ++current
|
||||||
|
const active = () => token === current
|
||||||
|
|
||||||
|
const value = cleanInput(filter)
|
||||||
const scopedInput = scoped(value)
|
const scopedInput = scoped(value)
|
||||||
if (!scopedInput) return [] as string[]
|
if (!scopedInput) return [] as string[]
|
||||||
|
|
||||||
const raw = normalizeDriveRoot(value)
|
const raw = normalizeDriveRoot(value)
|
||||||
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
|
||||||
|
|
||||||
const query = normalizeDriveRoot(scopedInput.path)
|
const query = normalizeDriveRoot(scopedInput.path)
|
||||||
|
|
||||||
const find = () =>
|
const find = () =>
|
||||||
sdk.client.find
|
args.sdk.client.find
|
||||||
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|
||||||
if (!isPath) {
|
if (!isPath) {
|
||||||
const results = await find()
|
const results = await find()
|
||||||
|
if (!active()) return []
|
||||||
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
|
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = query.replace(/^\/+/, "").split("/")
|
const segments = query.replace(/^\/+/, "").split("/")
|
||||||
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
const branch = 4
|
const branch = 4
|
||||||
let paths = [scopedInput.directory]
|
let paths = [scopedInput.directory]
|
||||||
for (const part of head) {
|
for (const part of head) {
|
||||||
|
if (!active()) return []
|
||||||
if (part === "..") {
|
if (part === "..") {
|
||||||
paths = paths.map(parentOf)
|
paths = paths.map(parentOf)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
paths = Array.from(new Set(next)).slice(0, cap)
|
paths = Array.from(new Set(next)).slice(0, cap)
|
||||||
if (paths.length === 0) return [] as string[]
|
if (paths.length === 0) return [] as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
|
||||||
|
if (!active()) return []
|
||||||
const deduped = Array.from(new Set(out))
|
const deduped = Array.from(new Set(out))
|
||||||
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
|
||||||
const expand = !raw.endsWith("/")
|
const expand = !raw.endsWith("/")
|
||||||
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
if (!target) return deduped.slice(0, 50)
|
if (!target) return deduped.slice(0, 50)
|
||||||
|
|
||||||
const children = await match(target, "", 30)
|
const children = await match(target, "", 30)
|
||||||
|
if (!active()) return []
|
||||||
const items = Array.from(new Set([...deduped, ...children]))
|
const items = Array.from(new Set([...deduped, ...children]))
|
||||||
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const sdk = useGlobalSDK()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const [filter, setFilter] = createSignal("")
|
||||||
|
let list: ListRef | undefined
|
||||||
|
|
||||||
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
|
const [fallbackPath] = createResource(
|
||||||
|
() => (missingBase() ? true : undefined),
|
||||||
|
async () => {
|
||||||
|
return sdk.client.path
|
||||||
|
.get()
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
|
{ initialValue: undefined },
|
||||||
|
)
|
||||||
|
|
||||||
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
const start = createMemo(
|
||||||
|
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
const directories = useDirectorySearch({
|
||||||
|
sdk,
|
||||||
|
home,
|
||||||
|
start,
|
||||||
|
})
|
||||||
|
|
||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
return results.map(row)
|
return results.map((absolute) => toRow(absolute, home()))
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
key={(x) => x.absolute}
|
key={(x) => x.absolute}
|
||||||
filterKeys={["search"]}
|
filterKeys={["search"]}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(clean(value))}
|
onFilter={(value) => setFilter(cleanInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
if (e.key !== "Tab") return
|
if (e.key !== "Tab") return
|
||||||
if (e.shiftKey) return
|
if (e.shiftKey) return
|
||||||
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const value = display(item.absolute, filter())
|
const value = displayPath(item.absolute, filter(), home())
|
||||||
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
list?.setFilter(value.endsWith("/") ? value : value + "/")
|
||||||
}}
|
}}
|
||||||
onSelect={(path) => {
|
onSelect={(path) => {
|
||||||
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const path = display(item.absolute, filter())
|
const path = displayPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div class="w-full flex items-center justify-between rounded-md">
|
||||||
|
|||||||
@@ -36,6 +36,223 @@ type Entry = {
|
|||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
|
|
||||||
|
const ENTRY_LIMIT = 5
|
||||||
|
const COMMON_COMMAND_IDS = [
|
||||||
|
"session.new",
|
||||||
|
"workspace.new",
|
||||||
|
"session.previous",
|
||||||
|
"session.next",
|
||||||
|
"terminal.toggle",
|
||||||
|
"review.toggle",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const uniqueEntries = (items: Entry[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: Entry[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
||||||
|
id: "command:" + option.id,
|
||||||
|
type: "command",
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
keybind: option.keybind,
|
||||||
|
category,
|
||||||
|
option,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createFileEntry = (path: string, category: string): Entry => ({
|
||||||
|
id: "file:" + path,
|
||||||
|
type: "file",
|
||||||
|
title: path,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSessionEntry = (
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
},
|
||||||
|
category: string,
|
||||||
|
): Entry => ({
|
||||||
|
id: `session:${input.directory}:${input.id}`,
|
||||||
|
type: "session",
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
category,
|
||||||
|
directory: input.directory,
|
||||||
|
sessionID: input.id,
|
||||||
|
archived: input.archived,
|
||||||
|
updated: input.updated,
|
||||||
|
})
|
||||||
|
|
||||||
|
function createCommandEntries(props: {
|
||||||
|
filesOnly: () => boolean
|
||||||
|
command: ReturnType<typeof useCommand>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const allowed = createMemo(() => {
|
||||||
|
if (props.filesOnly()) return []
|
||||||
|
return props.command.options.filter(
|
||||||
|
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return allowed().map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const picks = createMemo(() => {
|
||||||
|
const all = allowed()
|
||||||
|
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||||
|
const picked = all.filter((option) => order.has(option.id))
|
||||||
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
|
const category = props.language.t("palette.group.commands")
|
||||||
|
return sorted.map((option) => createCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { allowed, list, picks }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileEntries(props: {
|
||||||
|
file: ReturnType<typeof useFile>
|
||||||
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
const all = props.tabs().all()
|
||||||
|
const active = props.tabs().active()
|
||||||
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const items: Entry[] = []
|
||||||
|
|
||||||
|
for (const item of order) {
|
||||||
|
const path = props.file.pathFromTab(item)
|
||||||
|
if (!path) continue
|
||||||
|
if (seen.has(path)) continue
|
||||||
|
seen.add(path)
|
||||||
|
items.push(createFileEntry(path, category))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, ENTRY_LIMIT)
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = createMemo(() => {
|
||||||
|
const category = props.language.t("palette.group.files")
|
||||||
|
const nodes = props.file.tree.children("")
|
||||||
|
const paths = nodes
|
||||||
|
.filter((node) => node.type === "file")
|
||||||
|
.map((node) => node.path)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { recent, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionEntries(props: {
|
||||||
|
workspaces: () => string[]
|
||||||
|
label: (directory: string) => string
|
||||||
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
const state: {
|
||||||
|
token: number
|
||||||
|
inflight: Promise<Entry[]> | undefined
|
||||||
|
cached: Entry[] | undefined
|
||||||
|
} = {
|
||||||
|
token: 0,
|
||||||
|
inflight: undefined,
|
||||||
|
cached: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
if (!query) {
|
||||||
|
state.token += 1
|
||||||
|
state.inflight = undefined
|
||||||
|
state.cached = undefined
|
||||||
|
return [] as Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cached) return state.cached
|
||||||
|
if (state.inflight) return state.inflight
|
||||||
|
|
||||||
|
const current = state.token
|
||||||
|
const dirs = props.workspaces()
|
||||||
|
if (dirs.length === 0) return [] as Entry[]
|
||||||
|
|
||||||
|
state.inflight = Promise.all(
|
||||||
|
dirs.map((directory) => {
|
||||||
|
const description = props.label(directory)
|
||||||
|
return props.globalSDK.client.session
|
||||||
|
.list({ directory, roots: true })
|
||||||
|
.then((x) =>
|
||||||
|
(x.data ?? [])
|
||||||
|
.filter((s) => !!s?.id)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title ?? props.language.t("command.session.new"),
|
||||||
|
description,
|
||||||
|
directory,
|
||||||
|
archived: s.time?.archived,
|
||||||
|
updated: s.time?.updated,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch(
|
||||||
|
() =>
|
||||||
|
[] as {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
directory: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}[],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (state.token !== current) return [] as Entry[]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = props.language.t("command.category.session")
|
||||||
|
const next = results
|
||||||
|
.flat()
|
||||||
|
.filter((item) => {
|
||||||
|
const key = `${item.directory}:${item.id}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => createSessionEntry(item, category))
|
||||||
|
state.cached = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
.catch(() => [] as Entry[])
|
||||||
|
.finally(() => {
|
||||||
|
state.inflight = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return state.inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions }
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const common = [
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
"session.new",
|
const fileEntries = createFileEntries({ file, tabs, language })
|
||||||
"workspace.new",
|
|
||||||
"session.previous",
|
|
||||||
"session.next",
|
|
||||||
"terminal.toggle",
|
|
||||||
"review.toggle",
|
|
||||||
]
|
|
||||||
const limit = 5
|
|
||||||
|
|
||||||
const allowed = createMemo(() => {
|
|
||||||
if (filesOnly()) return []
|
|
||||||
return command.options.filter(
|
|
||||||
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const commandItem = (option: CommandOption): Entry => ({
|
|
||||||
id: "command:" + option.id,
|
|
||||||
type: "command",
|
|
||||||
title: option.title,
|
|
||||||
description: option.description,
|
|
||||||
keybind: option.keybind,
|
|
||||||
category: language.t("palette.group.commands"),
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileItem = (path: string): Entry => ({
|
|
||||||
id: "file:" + path,
|
|
||||||
type: "file",
|
|
||||||
title: path,
|
|
||||||
category: language.t("palette.group.files"),
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
return `${kind} : ${name || path}`
|
return `${kind} : ${name || path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionItem = (input: {
|
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}): Entry => ({
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category: language.t("command.category.session"),
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() => allowed().map(commandItem))
|
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
|
||||||
const all = allowed()
|
|
||||||
const order = new Map(common.map((id, index) => [id, index]))
|
|
||||||
const picked = all.filter((option) => order.has(option.id))
|
|
||||||
const base = picked.length ? picked : all.slice(0, limit)
|
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
|
||||||
return sorted.map(commandItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const recent = createMemo(() => {
|
|
||||||
const all = tabs().all()
|
|
||||||
const active = tabs().active()
|
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const items: Entry[] = []
|
|
||||||
|
|
||||||
for (const item of order) {
|
|
||||||
const path = file.pathFromTab(item)
|
|
||||||
if (!path) continue
|
|
||||||
if (seen.has(path)) continue
|
|
||||||
seen.add(path)
|
|
||||||
items.push(fileItem(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, limit)
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = createMemo(() => {
|
|
||||||
const nodes = file.tree.children("")
|
|
||||||
const paths = nodes
|
|
||||||
.filter((node) => node.type === "file")
|
|
||||||
.map((node) => node.path)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
return paths.slice(0, limit).map(fileItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
const unique = (items: Entry[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: Entry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (seen.has(item.id)) continue
|
|
||||||
seen.add(item.id)
|
|
||||||
out.push(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionToken = { value: 0 }
|
|
||||||
let sessionInflight: Promise<Entry[]> | undefined
|
|
||||||
let sessionAll: Entry[] | undefined
|
|
||||||
|
|
||||||
const sessions = (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) {
|
|
||||||
sessionToken.value += 1
|
|
||||||
sessionInflight = undefined
|
|
||||||
sessionAll = undefined
|
|
||||||
return [] as Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionAll) return sessionAll
|
|
||||||
if (sessionInflight) return sessionInflight
|
|
||||||
|
|
||||||
const current = sessionToken.value
|
|
||||||
const dirs = workspaces()
|
|
||||||
if (dirs.length === 0) return [] as Entry[]
|
|
||||||
|
|
||||||
sessionInflight = Promise.all(
|
|
||||||
dirs.map((directory) => {
|
|
||||||
const description = label(directory)
|
|
||||||
return globalSDK.client.session
|
|
||||||
.list({ directory, roots: true })
|
|
||||||
.then((x) =>
|
|
||||||
(x.data ?? [])
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? language.t("command.session.new"),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: s.time?.archived,
|
|
||||||
updated: s.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((results) => {
|
|
||||||
if (sessionToken.value !== current) return [] as Entry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(sessionItem)
|
|
||||||
sessionAll = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
.catch(() => [] as Entry[])
|
|
||||||
.finally(() => {
|
|
||||||
sessionInflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return sessionInflight
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
if (!query && filesOnly()) {
|
if (!query && filesOnly()) {
|
||||||
const loaded = file.tree.state("")?.loaded
|
const loaded = file.tree.state("")?.loaded
|
||||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
||||||
const next = unique([...recent(), ...root()])
|
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
if (loaded || next.length > 0) {
|
||||||
void pending
|
void pending
|
||||||
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
return unique([...recent(), ...root()])
|
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) return [...picks(), ...recent()]
|
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
||||||
|
|
||||||
if (filesOnly()) {
|
if (filesOnly()) {
|
||||||
const files = await file.searchFiles(query)
|
const files = await file.searchFiles(query)
|
||||||
return files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
|
return files.map((path) => createFileEntry(path, category))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
||||||
const entries = files.map(fileItem)
|
const category = language.t("palette.group.files")
|
||||||
return [...list(), ...nextSessions, ...entries]
|
const entries = files.map((path) => createFileEntry(path, category))
|
||||||
|
return [...commandEntries.list(), ...nextSessions, ...entries]
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (item: Entry | undefined) => {
|
const handleMove = (item: Entry | undefined) => {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
|
|||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
connected: "mcp.status.connected",
|
||||||
|
failed: "mcp.status.failed",
|
||||||
|
needs_auth: "mcp.status.needs_auth",
|
||||||
|
disabled: "mcp.status.disabled",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const toggle = async (name: string) => {
|
const toggle = async (name: string) => {
|
||||||
if (loading()) return
|
if (loading()) return
|
||||||
setLoading(name)
|
setLoading(name)
|
||||||
const status = sync.data.mcp[name]
|
try {
|
||||||
if (status?.status === "connected") {
|
const status = sync.data.mcp[name]
|
||||||
await sdk.client.mcp.disconnect({ name })
|
if (status?.status === "connected") {
|
||||||
} else {
|
await sdk.client.mcp.disconnect({ name })
|
||||||
await sdk.client.mcp.connect({ name })
|
} else {
|
||||||
|
await sdk.client.mcp.connect({ name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sdk.client.mcp.status()
|
||||||
|
if (result.data) sync.set("mcp", result.data)
|
||||||
|
} finally {
|
||||||
|
setLoading(null)
|
||||||
}
|
}
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
setLoading(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync.data.mcp[i.name]
|
const mcpStatus = () => sync.data.mcp[i.name]
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
|
const statusLabel = () => {
|
||||||
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
if (!key) return
|
||||||
|
return language.t(key)
|
||||||
|
}
|
||||||
const error = () => {
|
const error = () => {
|
||||||
const s = mcpStatus()
|
const s = mcpStatus()
|
||||||
return s?.status === "failed" ? s.error : undefined
|
return s?.status === "failed" ? s.error : undefined
|
||||||
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={status() === "connected"}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
|
||||||
<Show when={status() === "failed"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "needs_auth"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={status() === "disabled"}>
|
|
||||||
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={loading() === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Component, onCleanup, onMount, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") return
|
if (e.key === "Escape") return
|
||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener("keydown", handleKey)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKey)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={language.t("dialog.model.select.title")}
|
title={language.t("dialog.model.select.title")}
|
||||||
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 px-2.5">
|
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
|
||||||
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
|
||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
|
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
|
|||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -54,13 +57,7 @@ const ModelList: Component<{
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
value={
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
<ModelTooltip
|
|
||||||
model={item}
|
|
||||||
latest={item.latest}
|
|
||||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -75,7 +72,7 @@ const ModelList: Component<{
|
|||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||||
<span class="truncate">{i.name}</span>
|
<span class="truncate">{i.name}</span>
|
||||||
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
|
<Show when={isFree(i.provider.id, i.cost)}>
|
||||||
<Tag>{language.t("model.tag.free")}</Tag>
|
<Tag>{language.t("model.tag.free")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.latest}>
|
<Show when={i.latest}>
|
||||||
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: "escape" | "outside" | null
|
||||||
trigger?: HTMLElement
|
|
||||||
content?: HTMLElement
|
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
trigger: undefined,
|
|
||||||
content: undefined,
|
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
|
|||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!store.open) return
|
|
||||||
|
|
||||||
const inside = (node: Node | null | undefined) => {
|
|
||||||
if (!node) return false
|
|
||||||
const el = store.content
|
|
||||||
if (el && el.contains(node)) return true
|
|
||||||
const anchor = store.trigger
|
|
||||||
if (anchor && anchor.contains(node)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape") return
|
|
||||||
setStore("dismiss", "escape")
|
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFocusIn = (event: FocusEvent) => {
|
|
||||||
if (!store.content) return
|
|
||||||
const target = event.target
|
|
||||||
if (!(target instanceof Node)) return
|
|
||||||
if (inside(target)) return
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown, true)
|
|
||||||
window.addEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.addEventListener("focusin", onFocusIn, true)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown, true)
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown, true)
|
|
||||||
window.removeEventListener("focusin", onFocusIn, true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Kobalte
|
<Kobalte
|
||||||
open={store.open}
|
open={store.open}
|
||||||
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
|
|||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={8}
|
gutter={8}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Kobalte.Trigger>
|
</Kobalte.Trigger>
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
ref={(el) => setStore("content", el)}
|
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
setStore("dismiss", "escape")
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||||
|
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||||
|
const note = (id: string) => {
|
||||||
|
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||||
|
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||||
|
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("command.provider.connect")} transition>
|
<Dialog title={language.t("command.provider.connect")} transition>
|
||||||
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
key={(x) => x?.id}
|
key={(x) => x?.id}
|
||||||
items={() => {
|
items={() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
|
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
|
||||||
}}
|
}}
|
||||||
filterKeys={["id", "name"]}
|
filterKeys={["id", "name"]}
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||||
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={i.id === "anthropic"}>
|
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === "openai"}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id.startsWith("github-copilot")}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -38,6 +38,64 @@ interface EditRowProps {
|
|||||||
onBlur: () => void
|
onBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
||||||
|
const [defaultUrl, defaultUrlActions] = createResource(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const url = await platform.getDefaultServerUrl?.()
|
||||||
|
if (!url) return null
|
||||||
|
return normalizeServerUrl(url) ?? null
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ initialValue: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
||||||
|
const setDefault = async (url: string | null) => {
|
||||||
|
try {
|
||||||
|
await platform.setDefaultServerUrl?.(url)
|
||||||
|
defaultUrlActions.mutate(url)
|
||||||
|
} catch (err) {
|
||||||
|
showRequestError(language, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { defaultUrl, canDefault, setDefault }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useServerPreview(fetcher: typeof fetch) {
|
||||||
|
const looksComplete = (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return false
|
||||||
|
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||||
|
if (!host) return false
|
||||||
|
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||||
|
return host.includes(".") || host.includes(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||||
|
setStatus(undefined)
|
||||||
|
if (!looksComplete(value)) return
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) return
|
||||||
|
const result = await checkServerHealth(normalized, fetcher)
|
||||||
|
setStatus(result.healthy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previewStatus }
|
||||||
|
}
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function AddRow(props: AddRowProps) {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||||
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
|
let listRoot: HTMLDivElement | undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<string, ServerHealth | undefined>,
|
status: {} as Record<string, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
|
|||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
|
||||||
if (!url) return null
|
|
||||||
return normalizeServerUrl(url) ?? null
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ initialValue: null },
|
|
||||||
)
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return false
|
|
||||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
|
||||||
if (!host) return false
|
|
||||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
|
||||||
return host.includes(".") || host.includes(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
|
||||||
setStatus(undefined)
|
|
||||||
if (!looksComplete(value)) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) return
|
|
||||||
const result = await checkServerHealth(normalized, fetcher)
|
|
||||||
setStatus(result.healthy)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollListToBottom = () => {
|
const scrollListToBottom = () => {
|
||||||
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
|
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
scroll.scrollTop = scroll.scrollHeight
|
||||||
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
|
|||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.server.title")}>
|
<Dialog title={language.t("dialog.server.title")}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<List
|
<div ref={(el) => (listRoot = el)}>
|
||||||
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
<List
|
||||||
noInitialSelection
|
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
|
||||||
emptyMessage={language.t("dialog.server.empty")}
|
noInitialSelection
|
||||||
items={sortedItems}
|
emptyMessage={language.t("dialog.server.empty")}
|
||||||
key={(x) => x}
|
items={sortedItems}
|
||||||
onSelect={(x) => {
|
key={(x) => x}
|
||||||
if (x) select(x)
|
onSelect={(x) => {
|
||||||
}}
|
if (x) select(x)
|
||||||
onFilter={(value) => {
|
}}
|
||||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
onFilter={(value) => {
|
||||||
resetAdd()
|
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||||
}
|
resetAdd()
|
||||||
}}
|
}
|
||||||
divider={true}
|
}}
|
||||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
divider={true}
|
||||||
add={
|
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||||
store.addServer.showForm
|
add={
|
||||||
? {
|
store.addServer.showForm
|
||||||
render: () => (
|
? {
|
||||||
<AddRow
|
render: () => (
|
||||||
value={store.addServer.url}
|
<AddRow
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
value={store.addServer.url}
|
||||||
adding={store.addServer.adding}
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
error={store.addServer.error}
|
adding={store.addServer.adding}
|
||||||
status={store.addServer.status}
|
error={store.addServer.error}
|
||||||
onChange={handleAddChange}
|
status={store.addServer.status}
|
||||||
onKeyDown={handleAddKey}
|
onChange={handleAddChange}
|
||||||
onBlur={blurAdd}
|
onKeyDown={handleAddKey}
|
||||||
/>
|
onBlur={blurAdd}
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(i) => {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
|
||||||
<Show
|
|
||||||
when={store.editServer.id !== i}
|
|
||||||
fallback={
|
|
||||||
<EditRow
|
|
||||||
value={store.editServer.value}
|
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
|
||||||
busy={store.editServer.busy}
|
|
||||||
error={store.editServer.error}
|
|
||||||
status={store.editServer.status}
|
|
||||||
onChange={handleEditChange}
|
|
||||||
onKeyDown={(event) => handleEditKey(event, i)}
|
|
||||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ServerRow
|
|
||||||
url={i}
|
|
||||||
status={store.status[i]}
|
|
||||||
dimmed={store.status[i]?.healthy === false}
|
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
|
||||||
badge={
|
|
||||||
<Show when={defaultUrl() === i}>
|
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
|
||||||
{language.t("dialog.server.status.default")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={store.editServer.id !== i}>
|
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
|
||||||
<Show when={current() === i}>
|
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
),
|
||||||
<DropdownMenu.Content class="mt-1">
|
}
|
||||||
<DropdownMenu.Item
|
: undefined
|
||||||
onSelect={() => {
|
}
|
||||||
setStore("editServer", {
|
>
|
||||||
id: i,
|
{(i) => {
|
||||||
value: i,
|
return (
|
||||||
error: "",
|
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||||
status: store.status[i]?.healthy,
|
<Show
|
||||||
})
|
when={store.editServer.id !== i}
|
||||||
}}
|
fallback={
|
||||||
>
|
<EditRow
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
value={store.editServer.value}
|
||||||
</DropdownMenu.Item>
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
<Show when={canDefault() && defaultUrl() !== i}>
|
busy={store.editServer.busy}
|
||||||
|
error={store.editServer.error}
|
||||||
|
status={store.editServer.status}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
onKeyDown={(event) => handleEditKey(event, i)}
|
||||||
|
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ServerRow
|
||||||
|
url={i}
|
||||||
|
status={store.status[i]}
|
||||||
|
dimmed={store.status[i]?.healthy === false}
|
||||||
|
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||||
|
badge={
|
||||||
|
<Show when={defaultUrl() === i}>
|
||||||
|
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
|
{language.t("dialog.server.status.default")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.editServer.id !== i}>
|
||||||
|
<div class="flex items-center justify-center gap-5 pl-4">
|
||||||
|
<Show when={current() === i}>
|
||||||
|
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||||
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={() => {
|
||||||
try {
|
setStore("editServer", {
|
||||||
await platform.setDefaultServerUrl?.(i)
|
id: i,
|
||||||
defaultUrlActions.mutate(i)
|
value: i,
|
||||||
} catch (err) {
|
error: "",
|
||||||
showToast({
|
status: store.status[i]?.healthy,
|
||||||
variant: "error",
|
})
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
<Show when={canDefault() && defaultUrl() !== i}>
|
||||||
<Show when={canDefault() && defaultUrl() === i}>
|
<DropdownMenu.Item onSelect={() => setDefault(i)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.default")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<Show when={canDefault() && defaultUrl() === i}>
|
||||||
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
|
<DropdownMenu.ItemLabel>
|
||||||
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={() => handleRemove(i)}
|
||||||
try {
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
await platform.setDefaultServerUrl?.(null)
|
|
||||||
defaultUrlActions.mutate(null)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</DropdownMenu.Content>
|
||||||
<DropdownMenu.Separator />
|
</DropdownMenu.Portal>
|
||||||
<DropdownMenu.Item
|
</DropdownMenu>
|
||||||
onSelect={() => handleRemove(i)}
|
</div>
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
</Show>
|
||||||
>
|
</div>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
)
|
||||||
</DropdownMenu.Item>
|
}}
|
||||||
</DropdownMenu.Content>
|
</List>
|
||||||
</DropdownMenu.Portal>
|
</div>
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
|
|||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsAgents /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsCommands /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
|
|
||||||
{/* <SettingsMcp /> */}
|
|
||||||
{/* </Tabs.Content> */}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
untrack,
|
untrack,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
type JSXElement,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
|
|||||||
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindLabel = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "A"
|
||||||
|
if (kind === "del") return "D"
|
||||||
|
return "M"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindTextColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "color: var(--icon-diff-delete-base)"
|
||||||
|
return "color: var(--icon-warning-active)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindDotColor = (kind: Kind) => {
|
||||||
|
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
|
||||||
|
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
|
||||||
|
return "background-color: var(--icon-warning-active)"
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||||
|
const kind = kinds?.get(node.path)
|
||||||
|
if (!kind) return
|
||||||
|
if (!marks?.has(node.path)) return
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDragImage = (target: HTMLElement) => {
|
||||||
|
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
|
||||||
|
const text = target.querySelector("span")
|
||||||
|
if (!icon || !text) return
|
||||||
|
|
||||||
|
const image = document.createElement("div")
|
||||||
|
image.className =
|
||||||
|
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
||||||
|
image.style.position = "absolute"
|
||||||
|
image.style.top = "-1000px"
|
||||||
|
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
const withFileDragImage = (event: DragEvent) => {
|
||||||
|
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||||
|
if (!image) return
|
||||||
|
document.body.appendChild(image)
|
||||||
|
event.dataTransfer?.setDragImage(image, 0, 12)
|
||||||
|
setTimeout(() => document.body.removeChild(image), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNode = (
|
||||||
|
p: ParentProps &
|
||||||
|
ComponentProps<"div"> &
|
||||||
|
ComponentProps<"button"> & {
|
||||||
|
node: FileNode
|
||||||
|
level: number
|
||||||
|
active?: string
|
||||||
|
nodeClass?: string
|
||||||
|
draggable: boolean
|
||||||
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
|
marks?: Set<string>
|
||||||
|
as?: "div" | "button"
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [local, rest] = splitProps(p, [
|
||||||
|
"node",
|
||||||
|
"level",
|
||||||
|
"active",
|
||||||
|
"nodeClass",
|
||||||
|
"draggable",
|
||||||
|
"kinds",
|
||||||
|
"marks",
|
||||||
|
"as",
|
||||||
|
"children",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
])
|
||||||
|
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||||
|
const active = () => !!kind() && !local.node.ignored
|
||||||
|
const color = () => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return
|
||||||
|
return kindTextColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dynamic
|
||||||
|
component={local.as ?? "div"}
|
||||||
|
classList={{
|
||||||
|
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
||||||
|
"bg-surface-base-active": local.node.path === local.active,
|
||||||
|
...(local.classList ?? {}),
|
||||||
|
[local.class ?? ""]: !!local.class,
|
||||||
|
[local.nodeClass ?? ""]: !!local.nodeClass,
|
||||||
|
}}
|
||||||
|
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
||||||
|
draggable={local.draggable}
|
||||||
|
onDragStart={(event: DragEvent) => {
|
||||||
|
if (!local.draggable) return
|
||||||
|
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||||
|
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||||
|
withFileDragImage(event)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
||||||
|
"text-text-weaker": local.node.ignored,
|
||||||
|
"text-text-weak": !local.node.ignored && !active(),
|
||||||
|
}}
|
||||||
|
style={active() ? color() : undefined}
|
||||||
|
>
|
||||||
|
{local.node.name}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const value = kind()
|
||||||
|
if (!value) return null
|
||||||
|
if (local.node.type === "file") {
|
||||||
|
return (
|
||||||
|
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
|
||||||
|
{kindLabel(value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
|
||||||
|
})()}
|
||||||
|
</Dynamic>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
|
||||||
|
if (!props.enabled) return props.children
|
||||||
|
|
||||||
|
const parts = props.node.path.split("/")
|
||||||
|
const leaf = parts[parts.length - 1] ?? props.node.path
|
||||||
|
const head = parts.slice(0, -1).join("/")
|
||||||
|
const prefix = head ? `${head}/` : ""
|
||||||
|
const label =
|
||||||
|
props.kind === "add"
|
||||||
|
? "Additions"
|
||||||
|
: props.kind === "del"
|
||||||
|
? "Deletions"
|
||||||
|
: props.kind === "mix"
|
||||||
|
? "Modifications"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
openDelay={2000}
|
||||||
|
placement="bottom-start"
|
||||||
|
class="w-full"
|
||||||
|
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
||||||
|
value={
|
||||||
|
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
||||||
|
<span
|
||||||
|
class="min-w-0 truncate text-text-invert-base"
|
||||||
|
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
||||||
|
>
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
||||||
|
<Show when={label}>
|
||||||
|
{(text) => (
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">{text()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={props.node.type === "directory" && props.node.ignored}>
|
||||||
|
<>
|
||||||
|
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
||||||
|
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FileTree(props: {
|
export default function FileTree(props: {
|
||||||
path: string
|
path: string
|
||||||
class?: string
|
class?: string
|
||||||
@@ -230,178 +414,13 @@ export default function FileTree(props: {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
const Node = (
|
|
||||||
p: ParentProps &
|
|
||||||
ComponentProps<"div"> &
|
|
||||||
ComponentProps<"button"> & {
|
|
||||||
node: FileNode
|
|
||||||
as?: "div" | "button"
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
component={local.as ?? "div"}
|
|
||||||
classList={{
|
|
||||||
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
|
|
||||||
"bg-surface-base-active": local.node.path === props.active,
|
|
||||||
...(local.classList ?? {}),
|
|
||||||
[local.class ?? ""]: !!local.class,
|
|
||||||
[props.nodeClass ?? ""]: !!props.nodeClass,
|
|
||||||
}}
|
|
||||||
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
|
|
||||||
draggable={draggable()}
|
|
||||||
onDragStart={(e: DragEvent) => {
|
|
||||||
if (!draggable()) return
|
|
||||||
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
|
||||||
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
|
||||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
|
|
||||||
|
|
||||||
const dragImage = document.createElement("div")
|
|
||||||
dragImage.className =
|
|
||||||
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
|
|
||||||
dragImage.style.position = "absolute"
|
|
||||||
dragImage.style.top = "-1000px"
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
|
|
||||||
(e.currentTarget as HTMLElement).querySelector("svg")
|
|
||||||
const text = (e.currentTarget as HTMLElement).querySelector("span")
|
|
||||||
if (icon && text) {
|
|
||||||
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage)
|
|
||||||
e.dataTransfer?.setDragImage(dragImage, 0, 12)
|
|
||||||
setTimeout(() => document.body.removeChild(dragImage), 0)
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{local.children}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
const marked = marks()?.has(local.node.path) ?? false
|
|
||||||
const active = !!kind && marked && !local.node.ignored
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: kind === "mix"
|
|
||||||
? "color: var(--icon-warning-active)"
|
|
||||||
: undefined
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
|
|
||||||
"text-text-weaker": local.node.ignored,
|
|
||||||
"text-text-weak": !local.node.ignored && !active,
|
|
||||||
}}
|
|
||||||
style={active ? color : undefined}
|
|
||||||
>
|
|
||||||
{local.node.name}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const kind = kinds()?.get(local.node.path)
|
|
||||||
if (!kind) return null
|
|
||||||
if (!marks()?.has(local.node.path)) return null
|
|
||||||
|
|
||||||
if (local.node.type === "file") {
|
|
||||||
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "color: var(--icon-diff-delete-base)"
|
|
||||||
: "color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (local.node.type === "directory") {
|
|
||||||
const color =
|
|
||||||
kind === "add"
|
|
||||||
? "background-color: var(--icon-diff-add-base)"
|
|
||||||
: kind === "del"
|
|
||||||
? "background-color: var(--icon-diff-delete-base)"
|
|
||||||
: "background-color: var(--icon-warning-active)"
|
|
||||||
|
|
||||||
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
</Dynamic>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
|
||||||
<For each={nodes()}>
|
<For each={nodes()}>
|
||||||
{(node) => {
|
{(node) => {
|
||||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||||
const deep = () => deeps().get(node.path) ?? -1
|
const deep = () => deeps().get(node.path) ?? -1
|
||||||
const Wrapper = (p: ParentProps) => {
|
const kind = () => visibleKind(node, kinds(), marks())
|
||||||
if (!tooltip()) return p.children
|
|
||||||
|
|
||||||
const parts = node.path.split("/")
|
|
||||||
const leaf = parts[parts.length - 1] ?? node.path
|
|
||||||
const head = parts.slice(0, -1).join("/")
|
|
||||||
const prefix = head ? `${head}/` : ""
|
|
||||||
|
|
||||||
const kind = () => kinds()?.get(node.path)
|
|
||||||
const label = () => {
|
|
||||||
const k = kind()
|
|
||||||
if (!k) return
|
|
||||||
if (k === "add") return "Additions"
|
|
||||||
if (k === "del") return "Deletions"
|
|
||||||
return "Modifications"
|
|
||||||
}
|
|
||||||
|
|
||||||
const ignored = () => node.type === "directory" && node.ignored
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
openDelay={2000}
|
|
||||||
placement="bottom-start"
|
|
||||||
class="w-full"
|
|
||||||
contentStyle={{ "max-width": "480px", width: "fit-content" }}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
|
|
||||||
<span
|
|
||||||
class="min-w-0 truncate text-text-invert-base"
|
|
||||||
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
|
|
||||||
>
|
|
||||||
{prefix}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
|
|
||||||
<Show when={label()}>
|
|
||||||
{(t: () => string) => (
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">{t()}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={ignored()}>
|
|
||||||
<>
|
|
||||||
<span class="mx-1 font-bold text-text-invert-strong">•</span>
|
|
||||||
<span class="shrink-0 text-text-invert-strong">Ignored</span>
|
|
||||||
</>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.children}
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -415,13 +434,21 @@ export default function FileTree(props: {
|
|||||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
>
|
||||||
<div class="size-4 flex items-center justify-center text-icon-weak">
|
<div class="size-4 flex items-center justify-center text-icon-weak">
|
||||||
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
|
||||||
</div>
|
</div>
|
||||||
</Node>
|
</FileTreeNode>
|
||||||
</Wrapper>
|
</FileTreeNodeTooltip>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Content class="relative pt-0.5">
|
<Collapsible.Content class="relative pt-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -451,12 +478,23 @@ export default function FileTree(props: {
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={node.type === "file"}>
|
<Match when={node.type === "file"}>
|
||||||
<Wrapper>
|
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
|
||||||
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
|
<FileTreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
active={props.active}
|
||||||
|
nodeClass={props.nodeClass}
|
||||||
|
draggable={draggable()}
|
||||||
|
kinds={kinds()}
|
||||||
|
marks={marks()}
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onFileClick?.(node)}
|
||||||
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<FileIcon node={node} class="text-icon-weak size-4" />
|
<FileIcon node={node} class="text-icon-weak size-4" />
|
||||||
</Node>
|
</FileTreeNode>
|
||||||
</Wrapper>
|
</FileTreeNodeTooltip>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { ComponentProps, splitProps } from "solid-js"
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
export interface LinkProps extends ComponentProps<"button"> {
|
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Link(props: LinkProps) {
|
export function Link(props: LinkProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [local, rest] = splitProps(props, ["href", "children"])
|
const [local, rest] = splitProps(props, ["href", "children", "class"])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!local.href) return
|
||||||
|
event.preventDefault()
|
||||||
|
platform.openLink(local.href)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
{local.children}
|
{local.children}
|
||||||
</button>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
|
||||||
|
const closePopover = () => setStore("popover", null)
|
||||||
|
|
||||||
|
const resetHistoryNavigation = (force = false) => {
|
||||||
|
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
|
||||||
|
setStore("historyIndex", -1)
|
||||||
|
setStore("savedPrompt", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearEditor = () => {
|
||||||
|
editorRef.innerHTML = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const setEditorText = (text: string) => {
|
||||||
|
clearEditor()
|
||||||
|
editorRef.textContent = text
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusEditorEnd = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editorRef.focus()
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
range.selectNodeContents(editorRef)
|
||||||
|
range.collapse(false)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCursor = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
|
||||||
|
return getCursorPosition(editorRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
|
const cursor = currentCursor()
|
||||||
|
renderEditor(parts)
|
||||||
|
if (cursor !== null) setCursorPosition(editorRef, cursor)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
params.id
|
params.id
|
||||||
if (params.id) return
|
if (params.id) return
|
||||||
@@ -290,7 +331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!isFocused()) setStore("popover", null)
|
if (!isFocused()) closePopover()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Safety: reset composing state on focus change to prevent stuck state
|
// Safety: reset composing state on focus change to prevent stuck state
|
||||||
@@ -381,26 +422,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
const text = `/${cmd.trigger} `
|
const text = `/${cmd.trigger} `
|
||||||
editorRef.innerHTML = ""
|
setEditorText(text)
|
||||||
editorRef.textContent = text
|
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||||
requestAnimationFrame(() => {
|
focusEditorEnd()
|
||||||
editorRef.focus()
|
|
||||||
const range = document.createRange()
|
|
||||||
const sel = window.getSelection()
|
|
||||||
range.selectNodeContents(editorRef)
|
|
||||||
range.collapse(false)
|
|
||||||
sel?.removeAllRanges()
|
|
||||||
sel?.addRange(range)
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
editorRef.innerHTML = ""
|
clearEditor()
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
||||||
command.trigger(cmd.id, "slash")
|
command.trigger(cmd.id, "slash")
|
||||||
}
|
}
|
||||||
@@ -454,7 +486,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderEditor = (parts: Prompt) => {
|
const renderEditor = (parts: Prompt) => {
|
||||||
editorRef.innerHTML = ""
|
clearEditor()
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
editorRef.appendChild(createTextFragment(part.content))
|
editorRef.appendChild(createTextFragment(part.content))
|
||||||
@@ -514,34 +546,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mirror.input = false
|
mirror.input = false
|
||||||
if (isNormalizedEditor()) return
|
if (isNormalizedEditor()) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
renderEditorWithCursor(inputParts)
|
||||||
let cursorPosition: number | null = null
|
|
||||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
|
||||||
cursorPosition = getCursorPosition(editorRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEditor(inputParts)
|
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
renderEditorWithCursor(inputParts)
|
||||||
let cursorPosition: number | null = null
|
|
||||||
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
|
|
||||||
cursorPosition = getCursorPosition(editorRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEditor(inputParts)
|
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -636,11 +648,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
resetHistoryNavigation()
|
||||||
setStore("historyIndex", -1)
|
|
||||||
setStore("savedPrompt", null)
|
|
||||||
}
|
|
||||||
if (prompt.dirty()) {
|
if (prompt.dirty()) {
|
||||||
mirror.input = true
|
mirror.input = true
|
||||||
prompt.set(DEFAULT_PROMPT, 0)
|
prompt.set(DEFAULT_PROMPT, 0)
|
||||||
@@ -662,16 +671,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
slashOnInput(slashMatch[1])
|
slashOnInput(slashMatch[1])
|
||||||
setStore("popover", "slash")
|
setStore("popover", "slash")
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (store.historyIndex >= 0 && !store.applyingHistory) {
|
resetHistoryNavigation()
|
||||||
setStore("historyIndex", -1)
|
|
||||||
setStore("savedPrompt", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
mirror.input = true
|
mirror.input = true
|
||||||
prompt.set([...rawParts, ...images], cursorPosition)
|
prompt.set([...rawParts, ...images], cursorPosition)
|
||||||
@@ -732,7 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleInput()
|
handleInput()
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
@@ -782,8 +788,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
promptLength,
|
promptLength,
|
||||||
addToHistory,
|
addToHistory,
|
||||||
resetHistoryNavigation: () => {
|
resetHistoryNavigation: () => {
|
||||||
setStore("historyIndex", -1)
|
resetHistoryNavigation(true)
|
||||||
setStore("savedPrompt", null)
|
|
||||||
},
|
},
|
||||||
setMode: (mode) => setStore("mode", mode),
|
setMode: (mode) => setStore("mode", mode),
|
||||||
setPopover: (popover) => setStore("popover", popover),
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
@@ -872,7 +877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
if (ctrl && event.code === "KeyG") {
|
if (ctrl && event.code === "KeyG") {
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -923,7 +928,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
if (store.popover) {
|
if (store.popover) {
|
||||||
setStore("popover", null)
|
closePopover()
|
||||||
} else if (working()) {
|
} else if (working()) {
|
||||||
abort()
|
abort()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|||||||
<Show when={props.items.length > 0}>
|
<Show when={props.items.length > 0}>
|
||||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
<For each={props.items}>
|
<For each={props.items}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
<Tooltip
|
const directory = getDirectory(item.path)
|
||||||
value={
|
const filename = getFilename(item.path)
|
||||||
<span class="flex max-w-[300px]">
|
const label = getFilenameTruncated(item.path, 14)
|
||||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
const selected = props.active(item)
|
||||||
{getDirectory(item.path)}
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
value={
|
||||||
|
<span class="flex max-w-[300px]">
|
||||||
|
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||||
|
{directory}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0">{filename}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0">{getFilename(item.path)}</span>
|
}
|
||||||
</span>
|
placement="top"
|
||||||
}
|
openDelay={2000}
|
||||||
placement="top"
|
|
||||||
openDelay={2000}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
|
||||||
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
|
|
||||||
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
|
||||||
props.active(item),
|
|
||||||
"bg-background-stronger": !props.active(item),
|
|
||||||
}}
|
|
||||||
onClick={() => props.openComment(item)}
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
classList={{
|
||||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||||
<Show when={item.selection}>
|
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||||
{(sel) => (
|
selected,
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
"bg-background-stronger": !selected,
|
||||||
{sel().startLine === sel().endLine
|
}}
|
||||||
? `:${sel().startLine}`
|
onClick={() => props.openComment(item)}
|
||||||
: `:${sel().startLine}-${sel().endLine}`}
|
>
|
||||||
</span>
|
<div class="flex items-center gap-1.5">
|
||||||
)}
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
</Show>
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
|
<Show when={item.selection}>
|
||||||
|
{(sel) => (
|
||||||
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
|
{sel().startLine === sel().endLine
|
||||||
|
? `:${sel().startLine}`
|
||||||
|
: `:${sel().startLine}-${sel().endLine}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.remove(item)
|
||||||
|
}}
|
||||||
|
aria-label={props.t("prompt.context.removeFile")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<Show when={item.comment}>
|
||||||
type="button"
|
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||||
icon="close-small"
|
</Show>
|
||||||
variant="ghost"
|
|
||||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
props.remove(item)
|
|
||||||
}}
|
|
||||||
aria-label={props.t("prompt.context.removeFile")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.comment}>
|
</Tooltip>
|
||||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
)
|
||||||
</Show>
|
}}
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindToIcon = {
|
||||||
|
image: "photo",
|
||||||
|
"@mention": "link",
|
||||||
|
} as const
|
||||||
|
|
||||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.type !== null}>
|
<Show when={props.type !== null}>
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||||
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
|
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
|
||||||
<span class="text-14-regular">{props.label}</span>
|
<span class="text-14-regular">{props.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
|
|||||||
removeLabel: string
|
removeLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||||
|
const imageClass =
|
||||||
|
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||||
|
const removeClass =
|
||||||
|
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||||
|
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0}>
|
||||||
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<Show
|
<Show
|
||||||
when={attachment.mime.startsWith("image/")}
|
when={attachment.mime.startsWith("image/")}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
<div class={fallbackClass}>
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<img
|
<img
|
||||||
src={attachment.dataUrl}
|
src={attachment.dataUrl}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
class={imageClass}
|
||||||
onClick={() => props.onOpen(attachment)}
|
onClick={() => props.onOpen(attachment)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
class={removeClass}
|
||||||
aria-label={props.removeLabel}
|
aria-label={props.removeLabel}
|
||||||
>
|
>
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
<div class={nameClass}>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,47 +52,46 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
|||||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||||
>
|
>
|
||||||
<For each={props.atFlat.slice(0, 10)}>
|
<For each={props.atFlat.slice(0, 10)}>
|
||||||
{(item) => (
|
{(item) => {
|
||||||
<button
|
const active = props.atActive === props.atKey(item)
|
||||||
classList={{
|
const shared = {
|
||||||
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
|
||||||
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
|
"bg-surface-raised-base-hover": active,
|
||||||
}}
|
}
|
||||||
onClick={() => props.onAtSelect(item)}
|
|
||||||
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
if (item.type === "agent") {
|
||||||
>
|
return (
|
||||||
<Show
|
<button
|
||||||
when={item.type === "agent"}
|
classList={shared}
|
||||||
fallback={
|
onClick={() => props.onAtSelect(item)}
|
||||||
<>
|
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
||||||
<FileIcon
|
>
|
||||||
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
|
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||||
class="shrink-0 size-4"
|
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||||
/>
|
</button>
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
)
|
||||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
|
}
|
||||||
{item.type === "file"
|
|
||||||
? item.path.endsWith("/")
|
const isDirectory = item.path.endsWith("/")
|
||||||
? item.path
|
const directory = isDirectory ? item.path : getDirectory(item.path)
|
||||||
: getDirectory(item.path)
|
const filename = isDirectory ? "" : getFilename(item.path)
|
||||||
: ""}
|
|
||||||
</span>
|
return (
|
||||||
<Show when={item.type === "file" && !item.path.endsWith("/")}>
|
<button
|
||||||
<span class="text-text-strong whitespace-nowrap">
|
classList={shared}
|
||||||
{item.type === "file" ? getFilename(item.path) : ""}
|
onClick={() => props.onAtSelect(item)}
|
||||||
</span>
|
onMouseEnter={() => props.setAtActive(props.atKey(item))}
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
@{item.type === "agent" ? item.name : ""}
|
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||||
</span>
|
<Show when={!isDirectory}>
|
||||||
</Show>
|
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
||||||
</button>
|
</Show>
|
||||||
)}
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
||||||
|
const writeAt = <T,>(list: T[], index: number, value: T) => {
|
||||||
|
const next = [...list]
|
||||||
|
next[index] = value
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => {
|
||||||
|
return writeAt(list, index, [value])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => {
|
||||||
|
const current = list[index] ?? []
|
||||||
|
const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]
|
||||||
|
return writeAt(list, index, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => {
|
||||||
|
const current = list[index] ?? []
|
||||||
|
if (current.includes(value)) return list
|
||||||
|
return writeAt(list, index, [...current, value])
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeCustom = (list: string[], index: number, value: string) => {
|
||||||
|
return writeAt(list, index, value)
|
||||||
|
}
|
||||||
|
|
||||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -38,43 +64,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const reply = (answers: QuestionAnswer[]) => {
|
const reply = async (answers: QuestionAnswer[]) => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
setStore("sending", true)
|
setStore("sending", true)
|
||||||
sdk.client.question
|
try {
|
||||||
.reply({ requestID: props.request.id, answers })
|
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||||
.catch(fail)
|
} catch (err) {
|
||||||
.finally(() => setStore("sending", false))
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reject = () => {
|
const reject = async () => {
|
||||||
if (store.sending) return
|
if (store.sending) return
|
||||||
|
|
||||||
setStore("sending", true)
|
setStore("sending", true)
|
||||||
sdk.client.question
|
try {
|
||||||
.reject({ requestID: props.request.id })
|
await sdk.client.question.reject({ requestID: props.request.id })
|
||||||
.catch(fail)
|
} catch (err) {
|
||||||
.finally(() => setStore("sending", false))
|
fail(err)
|
||||||
|
} finally {
|
||||||
|
setStore("sending", false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
reply(questions().map((_, i) => store.answers[i] ?? []))
|
void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||||
}
|
}
|
||||||
|
|
||||||
const pick = (answer: string, custom: boolean = false) => {
|
const pick = (answer: string, custom: boolean = false) => {
|
||||||
const answers = [...store.answers]
|
setStore("answers", pickAnswer(store.answers, store.tab, answer))
|
||||||
answers[store.tab] = [answer]
|
|
||||||
setStore("answers", answers)
|
|
||||||
|
|
||||||
if (custom) {
|
if (custom) {
|
||||||
const inputs = [...store.custom]
|
setStore("custom", writeCustom(store.custom, store.tab, answer))
|
||||||
inputs[store.tab] = answer
|
|
||||||
setStore("custom", inputs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (single()) {
|
if (single()) {
|
||||||
reply([[answer]])
|
void reply([[answer]])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,15 +110,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggle = (answer: string) => {
|
const toggle = (answer: string) => {
|
||||||
const existing = store.answers[store.tab] ?? []
|
setStore("answers", toggleAnswer(store.answers, store.tab, answer))
|
||||||
const next = [...existing]
|
|
||||||
const index = next.indexOf(answer)
|
|
||||||
if (index === -1) next.push(answer)
|
|
||||||
if (index !== -1) next.splice(index, 1)
|
|
||||||
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = next
|
|
||||||
setStore("answers", answers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectTab = (index: number) => {
|
const selectTab = (index: number) => {
|
||||||
@@ -126,13 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (multi()) {
|
if (multi()) {
|
||||||
const existing = store.answers[store.tab] ?? []
|
setStore("answers", appendAnswer(store.answers, store.tab, value))
|
||||||
const next = [...existing]
|
|
||||||
if (!next.includes(value)) next.push(value)
|
|
||||||
|
|
||||||
const answers = [...store.answers]
|
|
||||||
answers[store.tab] = next
|
|
||||||
setStore("answers", answers)
|
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,9 +239,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
|||||||
value={input()}
|
value={input()}
|
||||||
disabled={store.sending}
|
disabled={store.sending}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const inputs = [...store.custom]
|
setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value))
|
||||||
inputs[store.tab] = e.currentTarget.value
|
|
||||||
setStore("custom", inputs)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { serverDisplayName } from "@/context/server"
|
import { serverDisplayName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
let nameRef: HTMLSpanElement | undefined
|
let nameRef: HTMLSpanElement | undefined
|
||||||
let versionRef: HTMLSpanElement | undefined
|
let versionRef: HTMLSpanElement | undefined
|
||||||
|
const name = createMemo(() => serverDisplayName(props.url))
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
name()
|
||||||
props.url
|
props.url
|
||||||
props.status?.version
|
props.status?.version
|
||||||
if (typeof requestAnimationFrame === "function") {
|
queueMicrotask(check)
|
||||||
requestAnimationFrame(check)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
check()
|
check()
|
||||||
if (typeof window === "undefined") return
|
if (typeof ResizeObserver !== "function") return
|
||||||
window.addEventListener("resize", check)
|
const observer = new ResizeObserver(check)
|
||||||
onCleanup(() => window.removeEventListener("resize", check))
|
if (nameRef) observer.observe(nameRef)
|
||||||
|
if (versionRef) observer.observe(versionRef)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
})
|
})
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span>{serverDisplayName(props.url)}</span>
|
<span>{name()}</span>
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span class="text-text-invert-base">{props.status?.version}</span>
|
<span class="text-text-invert-base">{props.status?.version}</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||||
{serverDisplayName(props.url)}
|
{name()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ interface SessionContextUsageProps {
|
|||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSessionContext(args: {
|
||||||
|
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
|
layout: ReturnType<typeof useLayout>
|
||||||
|
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
|
}) {
|
||||||
|
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
|
||||||
|
args.layout.fileTree.open()
|
||||||
|
args.layout.fileTree.setTab("all")
|
||||||
|
args.tabs.open("context")
|
||||||
|
args.tabs.setActive("context")
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -41,11 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
openSessionContext({
|
||||||
layout.fileTree.open()
|
view: view(),
|
||||||
layout.fileTree.setTab("all")
|
layout,
|
||||||
tabs().open("context")
|
tabs: tabs(),
|
||||||
tabs().setActive("context")
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const circle = () => (
|
const circle = () => (
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
|
||||||
|
|
||||||
|
const user = (id: string) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = (id: string) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1 },
|
||||||
|
} as unknown as Message
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("estimateSessionContextBreakdown", () => {
|
||||||
|
test("estimates tokens and keeps remaining tokens as other", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1")]
|
||||||
|
const parts = {
|
||||||
|
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
|
||||||
|
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = estimateSessionContextBreakdown({
|
||||||
|
messages,
|
||||||
|
parts,
|
||||||
|
input: 20,
|
||||||
|
systemPrompt: "system prompt",
|
||||||
|
})
|
||||||
|
|
||||||
|
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
|
||||||
|
expect(map.system).toBe(4)
|
||||||
|
expect(map.user).toBe(3)
|
||||||
|
expect(map.assistant).toBe(5)
|
||||||
|
expect(map.other).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("scales segments when estimates exceed input", () => {
|
||||||
|
const messages = [user("u1"), assistant("a1")]
|
||||||
|
const parts = {
|
||||||
|
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
|
||||||
|
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = estimateSessionContextBreakdown({
|
||||||
|
messages,
|
||||||
|
parts,
|
||||||
|
input: 10,
|
||||||
|
systemPrompt: "z".repeat(200),
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
|
||||||
|
expect(total).toBeLessThanOrEqual(10)
|
||||||
|
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
|
||||||
|
})
|
||||||
|
})
|
||||||
132
packages/app/src/components/session/session-context-breakdown.ts
Normal file
132
packages/app/src/components/session/session-context-breakdown.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
|
||||||
|
|
||||||
|
export type SessionContextBreakdownSegment = {
|
||||||
|
key: SessionContextBreakdownKey
|
||||||
|
tokens: number
|
||||||
|
width: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
||||||
|
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
|
||||||
|
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
|
||||||
|
|
||||||
|
const charsFromUserPart = (part: Part) => {
|
||||||
|
if (part.type === "text") return part.text.length
|
||||||
|
if (part.type === "file") return part.source?.text.value.length ?? 0
|
||||||
|
if (part.type === "agent") return part.source?.value.length ?? 0
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const charsFromAssistantPart = (part: Part) => {
|
||||||
|
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
|
||||||
|
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
|
||||||
|
if (part.type !== "tool") return { assistant: 0, tool: 0 }
|
||||||
|
|
||||||
|
const input = Object.keys(part.state.input).length * 16
|
||||||
|
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
|
||||||
|
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
|
||||||
|
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
|
||||||
|
return { assistant: 0, tool: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (
|
||||||
|
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
|
||||||
|
input: number,
|
||||||
|
) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "system",
|
||||||
|
tokens: tokens.system,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
tokens: tokens.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "assistant",
|
||||||
|
tokens: tokens.assistant,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tool",
|
||||||
|
tokens: tokens.tool,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
tokens: tokens.other,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.filter((x) => x.tokens > 0)
|
||||||
|
.map((x) => ({
|
||||||
|
key: x.key,
|
||||||
|
tokens: x.tokens,
|
||||||
|
width: toPercent(x.tokens, input),
|
||||||
|
percent: toPercentLabel(x.tokens, input),
|
||||||
|
})) as SessionContextBreakdownSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateSessionContextBreakdown(args: {
|
||||||
|
messages: Message[]
|
||||||
|
parts: Record<string, Part[] | undefined>
|
||||||
|
input: number
|
||||||
|
systemPrompt?: string
|
||||||
|
}) {
|
||||||
|
if (!args.input) return []
|
||||||
|
|
||||||
|
const counts = args.messages.reduce(
|
||||||
|
(acc, msg) => {
|
||||||
|
const parts = args.parts[msg.id] ?? []
|
||||||
|
if (msg.role === "user") {
|
||||||
|
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
|
||||||
|
return { ...acc, user: acc.user + user }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role !== "assistant") return acc
|
||||||
|
const assistant = parts.reduce(
|
||||||
|
(sum, part) => {
|
||||||
|
const next = charsFromAssistantPart(part)
|
||||||
|
return {
|
||||||
|
assistant: sum.assistant + next.assistant,
|
||||||
|
tool: sum.tool + next.tool,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ assistant: 0, tool: 0 },
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
assistant: acc.assistant + assistant.assistant,
|
||||||
|
tool: acc.tool + assistant.tool,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
system: args.systemPrompt?.length ?? 0,
|
||||||
|
user: 0,
|
||||||
|
assistant: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
system: estimateTokens(counts.system),
|
||||||
|
user: estimateTokens(counts.user),
|
||||||
|
assistant: estimateTokens(counts.assistant),
|
||||||
|
tool: estimateTokens(counts.tool),
|
||||||
|
}
|
||||||
|
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
|
||||||
|
|
||||||
|
if (estimated <= args.input) {
|
||||||
|
return build({ ...tokens, other: args.input - estimated }, args.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = args.input / estimated
|
||||||
|
const scaled = {
|
||||||
|
system: Math.floor(tokens.system * scale),
|
||||||
|
user: Math.floor(tokens.user * scale),
|
||||||
|
assistant: Math.floor(tokens.assistant * scale),
|
||||||
|
tool: Math.floor(tokens.tool * scale),
|
||||||
|
}
|
||||||
|
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
||||||
|
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
export function createSessionContextFormatter(locale: string) {
|
||||||
|
return {
|
||||||
|
number(value: number | null | undefined) {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toLocaleString(locale)
|
||||||
|
},
|
||||||
|
percent(value: number | null | undefined) {
|
||||||
|
if (value === undefined) return "—"
|
||||||
|
if (value === null) return "—"
|
||||||
|
return value.toLocaleString(locale) + "%"
|
||||||
|
},
|
||||||
|
time(value: number | undefined) {
|
||||||
|
if (!value) return "—"
|
||||||
|
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { DateTime } from "luxon"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
|||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
interface SessionContextTabProps {
|
interface SessionContextTabProps {
|
||||||
messages: () => Message[]
|
messages: () => Message[]
|
||||||
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
|
|||||||
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||||
|
system: "var(--syntax-info)",
|
||||||
|
user: "var(--syntax-success)",
|
||||||
|
assistant: "var(--syntax-property)",
|
||||||
|
tool: "var(--syntax-warning)",
|
||||||
|
other: "var(--syntax-comment)",
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat(props: { label: string; value: JSX.Element }) {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-12-regular text-text-weak">{props.label}</div>
|
||||||
|
<div class="text-12-medium text-text-strong">{props.value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
|
||||||
|
const file = createMemo(() => {
|
||||||
|
const parts = props.getParts(props.message.id)
|
||||||
|
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
||||||
|
return {
|
||||||
|
name: `${props.message.role}-${props.message.id}.json`,
|
||||||
|
contents,
|
||||||
|
cacheKey: checksum(contents),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Code
|
||||||
|
file={file()}
|
||||||
|
overflow="wrap"
|
||||||
|
class="select-text"
|
||||||
|
onRendered={() => requestAnimationFrame(props.onRendered)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RawMessage(props: {
|
||||||
|
message: Message
|
||||||
|
getParts: (id: string) => Part[]
|
||||||
|
onRendered: () => void
|
||||||
|
time: (value: number | undefined) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion.Item value={props.message.id}>
|
||||||
|
<StickyAccordionHeader>
|
||||||
|
<Accordion.Trigger>
|
||||||
|
<div class="flex items-center justify-between gap-2 w-full">
|
||||||
|
<div class="min-w-0 truncate">
|
||||||
|
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
|
||||||
|
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</StickyAccordionHeader>
|
||||||
|
<Accordion.Content class="bg-background-base">
|
||||||
|
<div class="p-3">
|
||||||
|
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
|
||||||
|
</div>
|
||||||
|
</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionContextTab(props: SessionContextTabProps) {
|
export function SessionContextTab(props: SessionContextTabProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
|
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(metrics().totalCost)
|
||||||
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
return trimmed
|
return trimmed
|
||||||
})
|
})
|
||||||
|
|
||||||
const number = (value: number | null | undefined) => {
|
|
||||||
if (value === undefined) return "—"
|
|
||||||
if (value === null) return "—"
|
|
||||||
return value.toLocaleString(language.locale())
|
|
||||||
}
|
|
||||||
|
|
||||||
const percent = (value: number | null | undefined) => {
|
|
||||||
if (value === undefined) return "—"
|
|
||||||
if (value === null) return "—"
|
|
||||||
return value.toLocaleString(language.locale()) + "%"
|
|
||||||
}
|
|
||||||
|
|
||||||
const time = (value: number | undefined) => {
|
|
||||||
if (!value) return "—"
|
|
||||||
return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerLabel = createMemo(() => {
|
const providerLabel = createMemo(() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c) return "—"
|
if (!c) return "—"
|
||||||
@@ -96,122 +149,23 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
|
||||||
() => {
|
() => {
|
||||||
const c = ctx()
|
const c = ctx()
|
||||||
if (!c) return []
|
if (!c?.input) return []
|
||||||
const input = c.input
|
return estimateSessionContextBreakdown({
|
||||||
if (!input) return []
|
messages: props.messages(),
|
||||||
|
parts: sync.data.part as Record<string, Part[] | undefined>,
|
||||||
const out = {
|
input: c.input,
|
||||||
system: systemPrompt()?.length ?? 0,
|
systemPrompt: systemPrompt(),
|
||||||
user: 0,
|
})
|
||||||
assistant: 0,
|
|
||||||
tool: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const msg of props.messages()) {
|
|
||||||
const parts = (sync.data.part[msg.id] ?? []) as Part[]
|
|
||||||
|
|
||||||
if (msg.role === "user") {
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === "text") out.user += part.text.length
|
|
||||||
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
|
|
||||||
if (part.type === "agent") out.user += part.source?.value.length ?? 0
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === "assistant") {
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.type === "text") out.assistant += part.text.length
|
|
||||||
if (part.type === "reasoning") out.assistant += part.text.length
|
|
||||||
if (part.type === "tool") {
|
|
||||||
out.tool += Object.keys(part.state.input).length * 16
|
|
||||||
if (part.state.status === "pending") out.tool += part.state.raw.length
|
|
||||||
if (part.state.status === "completed") out.tool += part.state.output.length
|
|
||||||
if (part.state.status === "error") out.tool += part.state.error.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
|
||||||
const system = estimateTokens(out.system)
|
|
||||||
const user = estimateTokens(out.user)
|
|
||||||
const assistant = estimateTokens(out.assistant)
|
|
||||||
const tool = estimateTokens(out.tool)
|
|
||||||
const estimated = system + user + assistant + tool
|
|
||||||
|
|
||||||
const pct = (tokens: number) => (tokens / input) * 100
|
|
||||||
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
|
|
||||||
|
|
||||||
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "system",
|
|
||||||
label: language.t("context.breakdown.system"),
|
|
||||||
tokens: tokens.system,
|
|
||||||
width: pct(tokens.system),
|
|
||||||
percent: pctLabel(tokens.system),
|
|
||||||
color: "var(--syntax-info)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "user",
|
|
||||||
label: language.t("context.breakdown.user"),
|
|
||||||
tokens: tokens.user,
|
|
||||||
width: pct(tokens.user),
|
|
||||||
percent: pctLabel(tokens.user),
|
|
||||||
color: "var(--syntax-success)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "assistant",
|
|
||||||
label: language.t("context.breakdown.assistant"),
|
|
||||||
tokens: tokens.assistant,
|
|
||||||
width: pct(tokens.assistant),
|
|
||||||
percent: pctLabel(tokens.assistant),
|
|
||||||
color: "var(--syntax-property)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tool",
|
|
||||||
label: language.t("context.breakdown.tool"),
|
|
||||||
tokens: tokens.tool,
|
|
||||||
width: pct(tokens.tool),
|
|
||||||
percent: pctLabel(tokens.tool),
|
|
||||||
color: "var(--syntax-warning)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "other",
|
|
||||||
label: language.t("context.breakdown.other"),
|
|
||||||
tokens: tokens.other,
|
|
||||||
width: pct(tokens.other),
|
|
||||||
percent: pctLabel(tokens.other),
|
|
||||||
color: "var(--syntax-comment)",
|
|
||||||
},
|
|
||||||
].filter((x) => x.tokens > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimated <= input) {
|
|
||||||
return build({ system, user, assistant, tool, other: input - estimated })
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = input / estimated
|
|
||||||
const scaled = {
|
|
||||||
system: Math.floor(system * scale),
|
|
||||||
user: Math.floor(user * scale),
|
|
||||||
assistant: Math.floor(assistant * scale),
|
|
||||||
tool: Math.floor(tool * scale),
|
|
||||||
}
|
|
||||||
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
|
||||||
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function Stat(statProps: { label: string; value: JSX.Element }) {
|
const breakdownLabel = (key: SessionContextBreakdownKey) => {
|
||||||
return (
|
if (key === "system") return language.t("context.breakdown.system")
|
||||||
<div class="flex flex-col gap-1">
|
if (key === "user") return language.t("context.breakdown.user")
|
||||||
<div class="text-12-regular text-text-weak">{statProps.label}</div>
|
if (key === "assistant") return language.t("context.breakdown.assistant")
|
||||||
<div class="text-12-medium text-text-strong">{statProps.value}</div>
|
if (key === "tool") return language.t("context.breakdown.tool")
|
||||||
</div>
|
return language.t("context.breakdown.other")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = createMemo(() => {
|
const stats = createMemo(() => {
|
||||||
@@ -222,15 +176,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
|
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
|
||||||
{ label: language.t("context.stats.provider"), value: providerLabel() },
|
{ label: language.t("context.stats.provider"), value: providerLabel() },
|
||||||
{ label: language.t("context.stats.model"), value: modelLabel() },
|
{ label: language.t("context.stats.model"), value: modelLabel() },
|
||||||
{ label: language.t("context.stats.limit"), value: number(c?.limit) },
|
{ label: language.t("context.stats.limit"), value: formatter().number(c?.limit) },
|
||||||
{ label: language.t("context.stats.totalTokens"), value: number(c?.total) },
|
{ label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) },
|
||||||
{ label: language.t("context.stats.usage"), value: percent(c?.usage) },
|
{ label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) },
|
||||||
{ label: language.t("context.stats.inputTokens"), value: number(c?.input) },
|
{ label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) },
|
||||||
{ label: language.t("context.stats.outputTokens"), value: number(c?.output) },
|
{ label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) },
|
||||||
{ label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
|
{ label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) },
|
||||||
{
|
{
|
||||||
label: language.t("context.stats.cacheTokens"),
|
label: language.t("context.stats.cacheTokens"),
|
||||||
value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
|
value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`,
|
||||||
},
|
},
|
||||||
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
|
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
|
||||||
{
|
{
|
||||||
@@ -238,55 +192,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
value: count.assistant.toLocaleString(language.locale()),
|
value: count.assistant.toLocaleString(language.locale()),
|
||||||
},
|
},
|
||||||
{ label: language.t("context.stats.totalCost"), value: cost() },
|
{ label: language.t("context.stats.totalCost"), value: cost() },
|
||||||
{ label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
|
{ label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) },
|
||||||
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
|
{ label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) },
|
||||||
] satisfies { label: string; value: JSX.Element }[]
|
] satisfies { label: string; value: JSX.Element }[]
|
||||||
})
|
})
|
||||||
|
|
||||||
function RawMessageContent(msgProps: { message: Message }) {
|
|
||||||
const file = createMemo(() => {
|
|
||||||
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
|
|
||||||
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
|
|
||||||
return {
|
|
||||||
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
|
|
||||||
contents,
|
|
||||||
cacheKey: checksum(contents),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RawMessage(msgProps: { message: Message }) {
|
|
||||||
return (
|
|
||||||
<Accordion.Item value={msgProps.message.id}>
|
|
||||||
<StickyAccordionHeader>
|
|
||||||
<Accordion.Trigger>
|
|
||||||
<div class="flex items-center justify-between gap-2 w-full">
|
|
||||||
<div class="min-w-0 truncate">
|
|
||||||
{msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
|
|
||||||
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</StickyAccordionHeader>
|
|
||||||
<Accordion.Content class="bg-background-base">
|
|
||||||
<div class="p-3">
|
|
||||||
<RawMessageContent message={msgProps.message} />
|
|
||||||
</div>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
|
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
|
||||||
|
|
||||||
const restoreScroll = () => {
|
const restoreScroll = () => {
|
||||||
const el = scroll
|
const el = scroll
|
||||||
@@ -356,7 +270,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${segment.width}%`,
|
width: `${segment.width}%`,
|
||||||
"background-color": segment.color,
|
"background-color": BREAKDOWN_COLOR[segment.key],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -366,9 +280,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
<For each={breakdown()}>
|
<For each={breakdown()}>
|
||||||
{(segment) => (
|
{(segment) => (
|
||||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||||
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
|
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||||
<div>{segment.label}</div>
|
<div>{breakdownLabel(segment.key)}</div>
|
||||||
<div class="text-text-weaker">{segment.percent}</div>
|
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -391,7 +305,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
|
<For each={props.messages()}>
|
||||||
|
{(message) => (
|
||||||
|
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { StatusPopover } from "../status-popover"
|
import { StatusPopover } from "../status-popover"
|
||||||
|
|
||||||
|
const OPEN_APPS = [
|
||||||
|
"vscode",
|
||||||
|
"cursor",
|
||||||
|
"zed",
|
||||||
|
"textmate",
|
||||||
|
"antigravity",
|
||||||
|
"finder",
|
||||||
|
"terminal",
|
||||||
|
"iterm2",
|
||||||
|
"ghostty",
|
||||||
|
"xcode",
|
||||||
|
"android-studio",
|
||||||
|
"powershell",
|
||||||
|
"sublime-text",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type OpenApp = (typeof OPEN_APPS)[number]
|
||||||
|
type OS = "macos" | "windows" | "linux" | "unknown"
|
||||||
|
|
||||||
|
const MAC_APPS = [
|
||||||
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
||||||
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
||||||
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
||||||
|
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
||||||
|
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
||||||
|
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
|
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
|
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
|
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
|
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
||||||
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const WINDOWS_APPS = [
|
||||||
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||||
|
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
||||||
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const LINUX_APPS = [
|
||||||
|
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
||||||
|
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
||||||
|
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
||||||
|
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
||||||
|
type OpenIcon = OpenApp | "file-explorer"
|
||||||
|
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
||||||
|
|
||||||
|
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
|
||||||
|
|
||||||
|
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
||||||
|
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||||
|
if (typeof navigator !== "object") return "unknown"
|
||||||
|
const value = navigator.platform || navigator.userAgent
|
||||||
|
if (/Mac/i.test(value)) return "macos"
|
||||||
|
if (/Win/i.test(value)) return "windows"
|
||||||
|
if (/Linux/i.test(value)) return "linux"
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSessionShare(args: {
|
||||||
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
|
currentSession: () =>
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
share?: {
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
projectDirectory: () => string
|
||||||
|
platform: ReturnType<typeof usePlatform>
|
||||||
|
}) {
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
share: false,
|
||||||
|
unshare: false,
|
||||||
|
copied: false,
|
||||||
|
timer: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const url = shareUrl()
|
||||||
|
if (url) return
|
||||||
|
if (state.timer) window.clearTimeout(state.timer)
|
||||||
|
setState({ copied: false, timer: undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.timer) window.clearTimeout(state.timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
const shareSession = () => {
|
||||||
|
const session = args.currentSession()
|
||||||
|
if (!session || state.share) return
|
||||||
|
setState("share", true)
|
||||||
|
args.globalSDK.client.session
|
||||||
|
.share({ sessionID: session.id, directory: args.projectDirectory() })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to share session", error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setState("share", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unshareSession = () => {
|
||||||
|
const session = args.currentSession()
|
||||||
|
if (!session || state.unshare) return
|
||||||
|
setState("unshare", true)
|
||||||
|
args.globalSDK.client.session
|
||||||
|
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to unshare session", error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setState("unshare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyLink = (onError: (error: unknown) => void) => {
|
||||||
|
const url = shareUrl()
|
||||||
|
if (!url) return
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(url)
|
||||||
|
.then(() => {
|
||||||
|
if (state.timer) window.clearTimeout(state.timer)
|
||||||
|
setState("copied", true)
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setState("copied", false)
|
||||||
|
setState("timer", undefined)
|
||||||
|
}, 3000)
|
||||||
|
setState("timer", timer)
|
||||||
|
})
|
||||||
|
.catch(onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewShare = () => {
|
||||||
|
const url = shareUrl()
|
||||||
|
if (!url) return
|
||||||
|
args.platform.openLink(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -53,62 +211,7 @@ export function SessionHeader() {
|
|||||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
|
const os = createMemo(() => detectOS(platform))
|
||||||
const OPEN_APPS = [
|
|
||||||
"vscode",
|
|
||||||
"cursor",
|
|
||||||
"zed",
|
|
||||||
"textmate",
|
|
||||||
"antigravity",
|
|
||||||
"finder",
|
|
||||||
"terminal",
|
|
||||||
"iterm2",
|
|
||||||
"ghostty",
|
|
||||||
"xcode",
|
|
||||||
"android-studio",
|
|
||||||
"powershell",
|
|
||||||
"sublime-text",
|
|
||||||
] as const
|
|
||||||
type OpenApp = (typeof OPEN_APPS)[number]
|
|
||||||
|
|
||||||
const MAC_APPS = [
|
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
|
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
|
||||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
|
||||||
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
|
|
||||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
|
||||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
|
||||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
|
||||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
|
||||||
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
|
|
||||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const WINDOWS_APPS = [
|
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
|
||||||
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
|
|
||||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const LINUX_APPS = [
|
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
|
||||||
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
|
|
||||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
|
||||||
if (typeof navigator !== "object") return "unknown"
|
|
||||||
const value = navigator.platform || navigator.userAgent
|
|
||||||
if (/Mac/i.test(value)) return "macos"
|
|
||||||
if (/Win/i.test(value)) return "windows"
|
|
||||||
if (/Linux/i.test(value)) return "linux"
|
|
||||||
return "unknown"
|
|
||||||
})
|
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
|
||||||
|
|
||||||
@@ -154,10 +257,6 @@ export function SessionHeader() {
|
|||||||
] as const
|
] as const
|
||||||
})
|
})
|
||||||
|
|
||||||
type OpenIcon = OpenApp | "file-explorer"
|
|
||||||
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
|
||||||
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
|
|
||||||
|
|
||||||
const checksReady = createMemo(() => {
|
const checksReady = createMemo(() => {
|
||||||
if (platform.platform !== "desktop") return true
|
if (platform.platform !== "desktop") return true
|
||||||
if (!platform.checkAppExists) return true
|
if (!platform.checkAppExists) return true
|
||||||
@@ -186,13 +285,7 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
const item = options().find((o) => o.id === app)
|
const item = options().find((o) => o.id === app)
|
||||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||||
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
|
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyPath = () => {
|
const copyPath = () => {
|
||||||
@@ -208,86 +301,15 @@ export function SessionHeader() {
|
|||||||
description: directory,
|
description: directory,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const share = useSessionShare({
|
||||||
share: false,
|
globalSDK,
|
||||||
unshare: false,
|
currentSession,
|
||||||
copied: false,
|
projectDirectory,
|
||||||
timer: undefined as number | undefined,
|
platform,
|
||||||
})
|
})
|
||||||
const shareUrl = createMemo(() => currentSession()?.share?.url)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const url = shareUrl()
|
|
||||||
if (url) return
|
|
||||||
if (state.timer) window.clearTimeout(state.timer)
|
|
||||||
setState({ copied: false, timer: undefined })
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.timer) window.clearTimeout(state.timer)
|
|
||||||
})
|
|
||||||
|
|
||||||
function shareSession() {
|
|
||||||
const session = currentSession()
|
|
||||||
if (!session || state.share) return
|
|
||||||
setState("share", true)
|
|
||||||
globalSDK.client.session
|
|
||||||
.share({ sessionID: session.id, directory: projectDirectory() })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to share session", error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setState("share", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function unshareSession() {
|
|
||||||
const session = currentSession()
|
|
||||||
if (!session || state.unshare) return
|
|
||||||
setState("unshare", true)
|
|
||||||
globalSDK.client.session
|
|
||||||
.unshare({ sessionID: session.id, directory: projectDirectory() })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to unshare session", error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setState("unshare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLink() {
|
|
||||||
const url = shareUrl()
|
|
||||||
if (!url) return
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(url)
|
|
||||||
.then(() => {
|
|
||||||
if (state.timer) window.clearTimeout(state.timer)
|
|
||||||
setState("copied", true)
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setState("copied", false)
|
|
||||||
setState("timer", undefined)
|
|
||||||
}, 3000)
|
|
||||||
setState("timer", timer)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to copy share link", error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewShare() {
|
|
||||||
const url = shareUrl()
|
|
||||||
if (!url) return
|
|
||||||
platform.openLink(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||||
@@ -391,7 +413,7 @@ export function SessionHeader() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||||
<AppIcon id={o.icon} class={size(o.icon)} />
|
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||||
<DropdownMenu.ItemIndicator>
|
<DropdownMenu.ItemIndicator>
|
||||||
@@ -428,7 +450,7 @@ export function SessionHeader() {
|
|||||||
<Popover
|
<Popover
|
||||||
title={language.t("session.share.popover.title")}
|
title={language.t("session.share.popover.title")}
|
||||||
description={
|
description={
|
||||||
shareUrl()
|
share.shareUrl()
|
||||||
? language.t("session.share.popover.description.shared")
|
? language.t("session.share.popover.description.shared")
|
||||||
: language.t("session.share.popover.description.unshared")
|
: language.t("session.share.popover.description.unshared")
|
||||||
}
|
}
|
||||||
@@ -441,24 +463,24 @@ export function SessionHeader() {
|
|||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class:
|
class:
|
||||||
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
|
||||||
classList: { "rounded-r-none": shareUrl() !== undefined },
|
classList: { "rounded-r-none": share.shareUrl() !== undefined },
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={language.t("session.share.action.share")}
|
trigger={language.t("session.share.action.share")}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={shareUrl()}
|
when={share.shareUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-1/2"
|
class="w-1/2"
|
||||||
onClick={shareSession}
|
onClick={share.shareSession}
|
||||||
disabled={state.share}
|
disabled={share.state.share}
|
||||||
>
|
>
|
||||||
{state.share
|
{share.state.share
|
||||||
? language.t("session.share.action.publishing")
|
? language.t("session.share.action.publishing")
|
||||||
: language.t("session.share.action.publish")}
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -467,7 +489,7 @@ export function SessionHeader() {
|
|||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<TextField
|
<TextField
|
||||||
value={shareUrl() ?? ""}
|
value={share.shareUrl() ?? ""}
|
||||||
readOnly
|
readOnly
|
||||||
copyable
|
copyable
|
||||||
copyKind="link"
|
copyKind="link"
|
||||||
@@ -479,10 +501,10 @@ export function SessionHeader() {
|
|||||||
size="large"
|
size="large"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
onClick={unshareSession}
|
onClick={share.unshareSession}
|
||||||
disabled={state.unshare}
|
disabled={share.state.unshare}
|
||||||
>
|
>
|
||||||
{state.unshare
|
{share.state.unshare
|
||||||
? language.t("session.share.action.unpublishing")
|
? language.t("session.share.action.unpublishing")
|
||||||
: language.t("session.share.action.unpublish")}
|
: language.t("session.share.action.unpublish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -490,8 +512,8 @@ export function SessionHeader() {
|
|||||||
size="large"
|
size="large"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={viewShare}
|
onClick={share.viewShare}
|
||||||
disabled={state.unshare}
|
disabled={share.state.unshare}
|
||||||
>
|
>
|
||||||
{language.t("session.share.action.view")}
|
{language.t("session.share.action.view")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -500,10 +522,10 @@ export function SessionHeader() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
|
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
value={
|
value={
|
||||||
state.copied
|
share.state.copied
|
||||||
? language.t("session.share.copy.copied")
|
? language.t("session.share.copy.copied")
|
||||||
: language.t("session.share.copy.copyLink")
|
: language.t("session.share.copy.copyLink")
|
||||||
}
|
}
|
||||||
@@ -511,13 +533,13 @@ export function SessionHeader() {
|
|||||||
gutter={8}
|
gutter={8}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={state.copied ? "check" : "link"}
|
icon={share.state.copied ? "check" : "link"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
|
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
|
||||||
onClick={copyLink}
|
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
||||||
disabled={state.unshare}
|
disabled={share.state.unshare}
|
||||||
aria-label={
|
aria-label={
|
||||||
state.copied
|
share.state.copied
|
||||||
? language.t("session.share.copy.copied")
|
? language.t("session.share.copy.copied")
|
||||||
: language.t("session.share.copy.copyLink")
|
: language.t("session.share.copy.copyLink")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|||||||
|
|
||||||
const MAIN_WORKTREE = "main"
|
const MAIN_WORKTREE = "main"
|
||||||
const CREATE_WORKTREE = "create"
|
const CREATE_WORKTREE = "create"
|
||||||
|
const ROOT_CLASS =
|
||||||
|
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
|
||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
|
<div class={ROOT_CLASS}>
|
||||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||||
<div class="flex justify-center items-center gap-3">
|
<div class="flex justify-center items-center gap-3">
|
||||||
<Icon name="folder" size="small" />
|
<Icon name="folder" size="small" />
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const sortable = createSortable(props.tab)
|
const sortable = createSortable(props.tab)
|
||||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||||
|
const content = createMemo(() => {
|
||||||
|
const value = path()
|
||||||
|
if (!value) return
|
||||||
|
return <FileVisual path={value} />
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
|
||||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||||
<div class="relative h-full">
|
<div class="relative h-full">
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
hideCloseButton
|
hideCloseButton
|
||||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
>
|
>
|
||||||
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
<Show when={content()}>{(value) => value()}</Show>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { Show } from "solid-js"
|
import { Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
menuPosition: { x: 0, y: 0 },
|
menuPosition: { x: 0, y: 0 },
|
||||||
blurEnabled: false,
|
blurEnabled: false,
|
||||||
})
|
})
|
||||||
|
let input: HTMLInputElement | undefined
|
||||||
|
let blurFrame: number | undefined
|
||||||
|
|
||||||
const isDefaultTitle = () => {
|
const isDefaultTitle = () => {
|
||||||
const number = props.terminal.titleNumber
|
const number = props.terminal.titleNumber
|
||||||
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
setStore("blurEnabled", false)
|
setStore("blurEnabled", false)
|
||||||
setStore("title", props.terminal.title)
|
setStore("title", props.terminal.title)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
setTimeout(() => {
|
|
||||||
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
|
|
||||||
if (!input) return
|
|
||||||
input.focus()
|
|
||||||
input.select()
|
|
||||||
setTimeout(() => setStore("blurEnabled", true), 100)
|
|
||||||
}, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
setStore("menuOpen", true)
|
setStore("menuOpen", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!store.editing) return
|
||||||
|
if (!input) return
|
||||||
|
input.focus()
|
||||||
|
input.select()
|
||||||
|
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
|
||||||
|
blurFrame = requestAnimationFrame(() => {
|
||||||
|
blurFrame = undefined
|
||||||
|
setStore("blurEnabled", true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (blurFrame === undefined) return
|
||||||
|
cancelAnimationFrame(blurFrame)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// @ts-ignore
|
|
||||||
use:sortable
|
use:sortable
|
||||||
class="outline-none focus:outline-none focus-visible:outline-none"
|
class="outline-none focus:outline-none focus-visible:outline-none"
|
||||||
classList={{
|
classList={{
|
||||||
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
<Show when={store.editing}>
|
<Show when={store.editing}>
|
||||||
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||||
<input
|
<input
|
||||||
id={`terminal-title-input-${props.terminal.id}`}
|
ref={input}
|
||||||
type="text"
|
type="text"
|
||||||
value={store.title}
|
value={store.title}
|
||||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export const SettingsAgents: Component = () => {
|
export const SettingsAgents: Component = () => {
|
||||||
|
// TODO: Replace this placeholder with full agents settings controls.
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export const SettingsCommands: Component = () => {
|
export const SettingsCommands: Component = () => {
|
||||||
|
// TODO: Replace this placeholder with full commands settings controls.
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
|
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -133,6 +133,261 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const soundOptions = [...SOUND_OPTIONS]
|
const soundOptions = [...SOUND_OPTIONS]
|
||||||
|
|
||||||
|
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
|
||||||
|
options: soundOptions,
|
||||||
|
current: soundOptions.find((o) => o.id === current()),
|
||||||
|
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||||
|
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||||
|
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
playDemoSound(option.src)
|
||||||
|
},
|
||||||
|
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
|
if (!option) return
|
||||||
|
set(option.id)
|
||||||
|
playDemoSound(option.src)
|
||||||
|
},
|
||||||
|
variant: "secondary" as const,
|
||||||
|
size: "small" as const,
|
||||||
|
triggerVariant: "settings" as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
const AppearanceSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||||
|
|
||||||
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.language.title")}
|
||||||
|
description={language.t("settings.general.row.language.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-language"
|
||||||
|
options={languageOptions()}
|
||||||
|
current={languageOptions().find((o) => o.value === language.locale())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && language.setLocale(option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.appearance.title")}
|
||||||
|
description={language.t("settings.general.row.appearance.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-color-scheme"
|
||||||
|
options={colorSchemeOptions()}
|
||||||
|
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||||
|
onHighlight={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
theme.previewColorScheme(option.value)
|
||||||
|
return () => theme.cancelPreview()
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.theme.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-theme"
|
||||||
|
options={themeOptions()}
|
||||||
|
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||||
|
value={(o) => o.id}
|
||||||
|
label={(o) => o.name}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
theme.setTheme(option.id)
|
||||||
|
}}
|
||||||
|
onHighlight={(option) => {
|
||||||
|
if (!option) return
|
||||||
|
theme.previewTheme(option.id)
|
||||||
|
return () => theme.cancelPreview()
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.font.title")}
|
||||||
|
description={language.t("settings.general.row.font.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-font"
|
||||||
|
options={fontOptionsList}
|
||||||
|
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => language.t(o.label)}
|
||||||
|
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||||
|
>
|
||||||
|
{(option) => (
|
||||||
|
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||||
|
{option ? language.t(option.label) : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const NotificationsSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||||
|
|
||||||
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-agent">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.agent()}
|
||||||
|
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.notifications.permissions.title")}
|
||||||
|
description={language.t("settings.general.notifications.permissions.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-permissions">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.permissions()}
|
||||||
|
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.notifications.errors.title")}
|
||||||
|
description={language.t("settings.general.notifications.errors.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-errors">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.errors()}
|
||||||
|
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SoundsSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
|
||||||
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.sounds.agent.title")}
|
||||||
|
description={language.t("settings.general.sounds.agent.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-sounds-agent"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.agent(),
|
||||||
|
(id) => settings.sounds.setAgent(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.sounds.permissions.title")}
|
||||||
|
description={language.t("settings.general.sounds.permissions.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-sounds-permissions"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.permissions(),
|
||||||
|
(id) => settings.sounds.setPermissions(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.sounds.errors.title")}
|
||||||
|
description={language.t("settings.general.sounds.errors.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-sounds-errors"
|
||||||
|
{...soundSelectProps(
|
||||||
|
() => settings.sounds.errors(),
|
||||||
|
(id) => settings.sounds.setErrors(id),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const UpdatesSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
|
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.updates.row.startup.title")}
|
||||||
|
description={language.t("settings.updates.row.startup.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-updates-startup">
|
||||||
|
<Switch
|
||||||
|
checked={settings.updates.startup()}
|
||||||
|
disabled={!platform.checkUpdate}
|
||||||
|
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.releaseNotes.title")}
|
||||||
|
description={language.t("settings.general.row.releaseNotes.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-release-notes">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.releaseNotes()}
|
||||||
|
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.updates.row.check.title")}
|
||||||
|
description={language.t("settings.updates.row.check.description")}
|
||||||
|
>
|
||||||
|
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||||
|
{store.checking
|
||||||
|
? language.t("settings.updates.action.checking")
|
||||||
|
: language.t("settings.updates.action.checkNow")}
|
||||||
|
</Button>
|
||||||
|
</SettingsRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||||
@@ -142,230 +397,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
<div class="flex flex-col gap-8 w-full">
|
||||||
{/* Appearance Section */}
|
<AppearanceSection />
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<NotificationsSection />
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.language.title")}
|
|
||||||
description={language.t("settings.general.row.language.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-language"
|
|
||||||
options={languageOptions()}
|
|
||||||
current={languageOptions().find((o) => o.value === language.locale())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && language.setLocale(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SoundsSection />
|
||||||
title={language.t("settings.general.row.appearance.title")}
|
|
||||||
description={language.t("settings.general.row.appearance.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-color-scheme"
|
|
||||||
options={colorSchemeOptions()}
|
|
||||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
|
||||||
onHighlight={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
theme.previewColorScheme(option.value)
|
|
||||||
return () => theme.cancelPreview()
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.theme.title")}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{language.t("settings.general.row.theme.description")}{" "}
|
|
||||||
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-theme"
|
|
||||||
options={themeOptions()}
|
|
||||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => o.name}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
theme.setTheme(option.id)
|
|
||||||
}}
|
|
||||||
onHighlight={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
theme.previewTheme(option.id)
|
|
||||||
return () => theme.cancelPreview()
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.font.title")}
|
|
||||||
description={language.t("settings.general.row.font.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-font"
|
|
||||||
options={fontOptionsList}
|
|
||||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => language.t(o.label)}
|
|
||||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
|
||||||
>
|
|
||||||
{(option) => (
|
|
||||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
|
||||||
{option ? language.t(option.label) : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System notifications Section */}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-agent">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.agent()}
|
|
||||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.permissions.title")}
|
|
||||||
description={language.t("settings.general.notifications.permissions.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-permissions">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.permissions()}
|
|
||||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.notifications.errors.title")}
|
|
||||||
description={language.t("settings.general.notifications.errors.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-notifications-errors">
|
|
||||||
<Switch
|
|
||||||
checked={settings.notifications.errors()}
|
|
||||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sound effects Section */}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.agent.title")}
|
|
||||||
description={language.t("settings.general.sounds.agent.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-agent"
|
|
||||||
options={soundOptions}
|
|
||||||
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => language.t(o.label)}
|
|
||||||
onHighlight={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.src)
|
|
||||||
}}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
settings.sounds.setAgent(option.id)
|
|
||||||
playDemoSound(option.src)
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.permissions.title")}
|
|
||||||
description={language.t("settings.general.sounds.permissions.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-permissions"
|
|
||||||
options={soundOptions}
|
|
||||||
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => language.t(o.label)}
|
|
||||||
onHighlight={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.src)
|
|
||||||
}}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
settings.sounds.setPermissions(option.id)
|
|
||||||
playDemoSound(option.src)
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.sounds.errors.title")}
|
|
||||||
description={language.t("settings.general.sounds.errors.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-sounds-errors"
|
|
||||||
options={soundOptions}
|
|
||||||
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
|
||||||
value={(o) => o.id}
|
|
||||||
label={(o) => language.t(o.label)}
|
|
||||||
onHighlight={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
playDemoSound(option.src)
|
|
||||||
}}
|
|
||||||
onSelect={(option) => {
|
|
||||||
if (!option) return
|
|
||||||
settings.sounds.setErrors(option.id)
|
|
||||||
playDemoSound(option.src)
|
|
||||||
}}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
|
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
@@ -395,53 +431,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Updates Section */}
|
<UpdatesSection />
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.updates.row.startup.title")}
|
|
||||||
description={language.t("settings.updates.row.startup.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-updates-startup">
|
|
||||||
<Switch
|
|
||||||
checked={settings.updates.startup()}
|
|
||||||
disabled={!platform.checkUpdate}
|
|
||||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.releaseNotes.title")}
|
|
||||||
description={language.t("settings.general.row.releaseNotes.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-release-notes">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.releaseNotes()}
|
|
||||||
onChange={(checked) => settings.general.setReleaseNotes(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.updates.row.check.title")}
|
|
||||||
description={language.t("settings.updates.row.check.description")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={store.checking || !platform.checkUpdate}
|
|
||||||
onClick={check}
|
|
||||||
>
|
|
||||||
{store.checking
|
|
||||||
? language.t("settings.updates.action.checking")
|
|
||||||
: language.t("settings.updates.action.checkNow")}
|
|
||||||
</Button>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={linux()}>
|
<Show when={linux()}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type KeybindMeta = {
|
|||||||
group: KeybindGroup
|
group: KeybindGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KeybindMap = Record<string, string | undefined>
|
||||||
|
type CommandContext = ReturnType<typeof useCommand>
|
||||||
|
|
||||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||||
|
|
||||||
type GroupKey =
|
type GroupKey =
|
||||||
@@ -107,6 +110,150 @@ function signatures(config: string | undefined) {
|
|||||||
return sigs
|
return sigs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function keybinds(value: unknown): KeybindMap {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||||
|
return value as KeybindMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
|
||||||
|
const out = new Map<string, KeybindMeta>()
|
||||||
|
out.set(PALETTE_ID, { title: palette, group: "General" })
|
||||||
|
|
||||||
|
for (const opt of command.catalog) {
|
||||||
|
if (opt.id.startsWith("suggested.")) continue
|
||||||
|
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const opt of command.options) {
|
||||||
|
if (opt.id.startsWith("suggested.")) continue
|
||||||
|
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, value] of Object.entries(map)) {
|
||||||
|
if (typeof value !== "string") continue
|
||||||
|
if (out.has(id)) continue
|
||||||
|
out.set(id, { title: id, group: groupFor(id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupedFor(list: Map<string, KeybindMeta>) {
|
||||||
|
const out = new Map<KeybindGroup, string[]>()
|
||||||
|
for (const group of GROUPS) out.set(group, [])
|
||||||
|
|
||||||
|
for (const [id, item] of list) {
|
||||||
|
const ids = out.get(item.group)
|
||||||
|
if (!ids) continue
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of GROUPS) {
|
||||||
|
const ids = out.get(group)
|
||||||
|
if (!ids) continue
|
||||||
|
ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredFor(
|
||||||
|
query: string,
|
||||||
|
list: Map<string, KeybindMeta>,
|
||||||
|
grouped: Map<KeybindGroup, string[]>,
|
||||||
|
keybind: (id: string) => string,
|
||||||
|
) {
|
||||||
|
const value = query.toLowerCase().trim()
|
||||||
|
if (!value) return grouped
|
||||||
|
|
||||||
|
const out = new Map<KeybindGroup, string[]>()
|
||||||
|
for (const group of GROUPS) out.set(group, [])
|
||||||
|
|
||||||
|
const items = Array.from(list.entries()).map(([id, meta]) => ({
|
||||||
|
id,
|
||||||
|
title: meta.title,
|
||||||
|
group: meta.group,
|
||||||
|
keybind: keybind(id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const results = fuzzysort.go(value, items, {
|
||||||
|
keys: ["title", "keybind"],
|
||||||
|
threshold: -10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const ids = out.get(result.obj.group)
|
||||||
|
if (!ids) continue
|
||||||
|
ids.push(result.obj.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function useKeyCapture(input: {
|
||||||
|
active: () => string | null
|
||||||
|
stop: () => void
|
||||||
|
set: (id: string, keybind: string) => void
|
||||||
|
used: () => Map<string, { id: string; title: string }[]>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) {
|
||||||
|
onMount(() => {
|
||||||
|
const handle = (event: KeyboardEvent) => {
|
||||||
|
const id = input.active()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
input.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear =
|
||||||
|
(event.key === "Backspace" || event.key === "Delete") &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
if (clear) {
|
||||||
|
input.set(id, "none")
|
||||||
|
input.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = recordKeybind(event)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const conflicts = new Map<string, string>()
|
||||||
|
for (const sig of signatures(next)) {
|
||||||
|
for (const item of input.used().get(sig) ?? []) {
|
||||||
|
if (item.id === id) continue
|
||||||
|
conflicts.set(item.id, item.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.size > 0) {
|
||||||
|
showToast({
|
||||||
|
title: input.language.t("settings.shortcuts.conflict.title"),
|
||||||
|
description: input.language.t("settings.shortcuts.conflict.description", {
|
||||||
|
keybind: formatKeybind(next),
|
||||||
|
titles: [...conflicts.values()].join(", "),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.set(id, next)
|
||||||
|
input.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handle, true)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handle, true))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsKeybinds: Component = () => {
|
export const SettingsKeybinds: Component = () => {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
command.keybinds(false)
|
command.keybinds(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasOverrides = createMemo(() => {
|
const map = createMemo(() => keybinds(settings.current.keybinds))
|
||||||
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
|
|
||||||
if (!keybinds) return false
|
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
|
||||||
return Object.values(keybinds).some((x) => typeof x === "string")
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetAll = () => {
|
const resetAll = () => {
|
||||||
stop()
|
stop()
|
||||||
@@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
|
|
||||||
const list = createMemo(() => {
|
const list = createMemo(() => {
|
||||||
language.locale()
|
language.locale()
|
||||||
const out = new Map<string, KeybindMeta>()
|
return listFor(command, map(), language.t("command.palette"))
|
||||||
out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
|
|
||||||
|
|
||||||
for (const opt of command.catalog) {
|
|
||||||
if (opt.id.startsWith("suggested.")) continue
|
|
||||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const opt of command.options) {
|
|
||||||
if (opt.id.startsWith("suggested.")) continue
|
|
||||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
|
||||||
}
|
|
||||||
|
|
||||||
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
|
|
||||||
if (keybinds) {
|
|
||||||
for (const [id, value] of Object.entries(keybinds)) {
|
|
||||||
if (typeof value !== "string") continue
|
|
||||||
if (out.has(id)) continue
|
|
||||||
out.set(id, { title: id, group: groupFor(id) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = (id: string) => list().get(id)?.title ?? ""
|
const title = (id: string) => list().get(id)?.title ?? ""
|
||||||
|
|
||||||
const grouped = createMemo(() => {
|
const grouped = createMemo(() => groupedFor(list()))
|
||||||
const map = list()
|
|
||||||
const out = new Map<KeybindGroup, string[]>()
|
|
||||||
|
|
||||||
for (const group of GROUPS) out.set(group, [])
|
|
||||||
|
|
||||||
for (const [id, item] of map) {
|
|
||||||
const ids = out.get(item.group)
|
|
||||||
if (!ids) continue
|
|
||||||
ids.push(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const group of GROUPS) {
|
|
||||||
const ids = out.get(group)
|
|
||||||
if (!ids) continue
|
|
||||||
|
|
||||||
ids.sort((a, b) => {
|
|
||||||
const at = map.get(a)?.title ?? ""
|
|
||||||
const bt = map.get(b)?.title ?? ""
|
|
||||||
return at.localeCompare(bt)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const query = store.filter.toLowerCase().trim()
|
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
|
||||||
if (!query) return grouped()
|
|
||||||
|
|
||||||
const map = list()
|
|
||||||
const out = new Map<KeybindGroup, string[]>()
|
|
||||||
|
|
||||||
for (const group of GROUPS) out.set(group, [])
|
|
||||||
|
|
||||||
const items = Array.from(map.entries()).map(([id, meta]) => ({
|
|
||||||
id,
|
|
||||||
title: meta.title,
|
|
||||||
group: meta.group,
|
|
||||||
keybind: command.keybind(id) || "",
|
|
||||||
}))
|
|
||||||
|
|
||||||
const results = fuzzysort.go(query, items, {
|
|
||||||
keys: ["title", "keybind"],
|
|
||||||
threshold: -10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const result of results) {
|
|
||||||
const item = result.obj
|
|
||||||
const ids = out.get(item.group)
|
|
||||||
if (!ids) continue
|
|
||||||
ids.push(item.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasResults = createMemo(() => {
|
const hasResults = createMemo(() => {
|
||||||
@@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
const setKeybind = (id: string, keybind: string) => {
|
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
|
||||||
settings.keybinds.set(id, keybind)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
useKeyCapture({
|
||||||
const handle = (event: KeyboardEvent) => {
|
active: () => store.active,
|
||||||
const id = store.active
|
stop,
|
||||||
if (!id) return
|
set: setKeybind,
|
||||||
|
used,
|
||||||
event.preventDefault()
|
language,
|
||||||
event.stopPropagation()
|
|
||||||
event.stopImmediatePropagation()
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear =
|
|
||||||
(event.key === "Backspace" || event.key === "Delete") &&
|
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event.shiftKey
|
|
||||||
if (clear) {
|
|
||||||
setKeybind(id, "none")
|
|
||||||
stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = recordKeybind(event)
|
|
||||||
if (!next) return
|
|
||||||
|
|
||||||
const map = used()
|
|
||||||
const conflicts = new Map<string, string>()
|
|
||||||
|
|
||||||
for (const sig of signatures(next)) {
|
|
||||||
const list = map.get(sig) ?? []
|
|
||||||
for (const item of list) {
|
|
||||||
if (item.id === id) continue
|
|
||||||
conflicts.set(item.id, item.title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conflicts.size > 0) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("settings.shortcuts.conflict.title"),
|
|
||||||
description: language.t("settings.shortcuts.conflict.description", {
|
|
||||||
keybind: formatKeybind(next),
|
|
||||||
titles: [...conflicts.values()].join(", "),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setKeybind(id, next)
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handle, true)
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handle, true)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export const SettingsMcp: Component = () => {
|
export const SettingsMcp: Component = () => {
|
||||||
|
// TODO: Replace this placeholder with full MCP settings controls.
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers"
|
|||||||
|
|
||||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||||
|
|
||||||
|
const ListLoadingState: Component<{ label: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<span class="text-14-regular text-text-weak">{props.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<span class="text-14-regular text-text-weak">{props.message}</span>
|
||||||
|
<Show when={props.filter}>
|
||||||
|
<span class="text-14-regular text-text-strong mt-1">"{props.filter}"</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsModels: Component = () => {
|
export const SettingsModels: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
@@ -68,24 +87,12 @@ export const SettingsModels: Component = () => {
|
|||||||
<Show
|
<Show
|
||||||
when={!list.grouped.loading}
|
when={!list.grouped.loading}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
|
||||||
<span class="text-14-regular text-text-weak">
|
|
||||||
{language.t("common.loading")}
|
|
||||||
{language.t("common.loading.ellipsis")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={list.flat().length > 0}
|
when={list.flat().length > 0}
|
||||||
fallback={
|
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
|
|
||||||
<Show when={list.filter()}>
|
|
||||||
<span class="text-14-regular text-text-strong mt-1">"{list.filter()}"</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<For each={list.grouped.latest}>
|
<For each={list.grouped.latest}>
|
||||||
{(group) => (
|
{(group) => (
|
||||||
|
|||||||
@@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => {
|
|||||||
const nextValue =
|
const nextValue =
|
||||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||||
|
|
||||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
const rollback = (err: unknown) => {
|
||||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
|
|
||||||
globalSync.set("config", "permission", before)
|
globalSync.set("config", "permission", before)
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
||||||
})
|
}
|
||||||
|
|
||||||
|
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||||
|
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
type ProviderMeta = { source?: ProviderSource }
|
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||||
|
|
||||||
|
const PROVIDER_NOTES = [
|
||||||
|
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||||
|
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||||
|
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||||
|
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||||
|
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
|
||||||
|
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
|
||||||
|
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||||
|
] as const
|
||||||
|
|
||||||
export const SettingsProviders: Component = () => {
|
export const SettingsProviders: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const source = (item: unknown) => (item as ProviderMeta).source
|
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||||
|
if (!("source" in item)) return
|
||||||
|
const value = item.source
|
||||||
|
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const type = (item: unknown) => {
|
const type = (item: ProviderItem) => {
|
||||||
const current = source(item)
|
const current = source(item)
|
||||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||||
if (current === "config") {
|
if (current === "config") {
|
||||||
const id = (item as { id?: string }).id
|
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||||
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
|
|
||||||
return language.t("settings.providers.tag.config")
|
return language.t("settings.providers.tag.config")
|
||||||
}
|
}
|
||||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: unknown) => source(item) !== "env"
|
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
|
||||||
|
|
||||||
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
const isConfigCustom = (providerID: string) => {
|
const isConfigCustom = (providerID: string) => {
|
||||||
const provider = globalSync.data.config.provider?.[providerID]
|
const provider = globalSync.data.config.provider?.[providerID]
|
||||||
@@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => {
|
|||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.id === "opencode"}>
|
<Show when={note(item.id)}>
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||||
{language.t("dialog.provider.opencode.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "anthropic"}>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("dialog.provider.anthropic.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id.startsWith("github-copilot")}>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("dialog.provider.copilot.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "openai"}>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("dialog.provider.openai.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "google"}>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("dialog.provider.google.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "openrouter"}>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("dialog.provider.openrouter.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.id === "vercel"}>
|
|
||||||
<span class="text-12-regular text-text-weak pl-8">
|
|
||||||
{language.t("dialog.provider.vercel.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
@@ -7,16 +7,151 @@ import { Tabs } from "@opencode-ai/ui/tabs"
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, useServer } from "@/context/server"
|
import { normalizeServerUrl, useServer } from "@/context/server"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
import { DialogSelectServer } from "./dialog-select-server"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { ServerRow } from "@/components/server/server-row"
|
import { ServerRow } from "@/components/server/server-row"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
const pollMs = 10_000
|
||||||
|
|
||||||
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
|
const parts = value.split(file)
|
||||||
|
if (parts.length === 1) return value
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts[0]}
|
||||||
|
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||||
|
{parts.slice(1).join(file)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listServersByHealth = (
|
||||||
|
list: string[],
|
||||||
|
active: string | undefined,
|
||||||
|
status: Record<string, ServerHealth | undefined>,
|
||||||
|
) => {
|
||||||
|
if (!list.length) return list
|
||||||
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
const rank = (value?: ServerHealth) => {
|
||||||
|
if (value?.healthy === true) return 0
|
||||||
|
if (value?.healthy === false) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
if (a === active) return -1
|
||||||
|
if (b === active) return 1
|
||||||
|
const diff = rank(status[a]) - rank(status[b])
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
|
||||||
|
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const list = servers()
|
||||||
|
let dead = false
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const results: Record<string, ServerHealth> = {}
|
||||||
|
await Promise.all(
|
||||||
|
list.map(async (url) => {
|
||||||
|
results[url] = await checkServerHealth(url, fetcher)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (dead) return
|
||||||
|
setStatus(reconcile(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh()
|
||||||
|
const id = setInterval(() => void refresh(), pollMs)
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
clearInterval(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const useDefaultServerUrl = (
|
||||||
|
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||||
|
) => {
|
||||||
|
const [url, setUrl] = createSignal<string | undefined>()
|
||||||
|
const [tick, setTick] = createSignal(0)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
tick()
|
||||||
|
let dead = false
|
||||||
|
const result = get?.()
|
||||||
|
if (!result) {
|
||||||
|
setUrl(undefined)
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.then((next) => {
|
||||||
|
if (dead) return
|
||||||
|
setUrl(next ? normalizeServerUrl(next) : undefined)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrl(normalizeServerUrl(result))
|
||||||
|
onCleanup(() => {
|
||||||
|
dead = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { url, refresh: () => setTick((value) => value + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const useMcpToggle = (input: {
|
||||||
|
sync: ReturnType<typeof useSync>
|
||||||
|
sdk: ReturnType<typeof useSDK>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const toggle = async (name: string) => {
|
||||||
|
if (loading()) return
|
||||||
|
setLoading(name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = input.sync.data.mcp[name]
|
||||||
|
await (status?.status === "connected"
|
||||||
|
? input.sdk.client.mcp.disconnect({ name })
|
||||||
|
: input.sdk.client.mcp.connect({ name }))
|
||||||
|
const result = await input.sdk.client.mcp.status()
|
||||||
|
if (result.data) input.sync.set("mcp", result.data)
|
||||||
|
} catch (err) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, toggle }
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -26,115 +161,35 @@ export function StatusPopover() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
status: {} as Record<string, ServerHealth | undefined>,
|
|
||||||
loading: null as string | null,
|
|
||||||
defaultServerUrl: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.url
|
||||||
const list = server.list
|
const list = server.list
|
||||||
if (!current) return list
|
if (!current) return list
|
||||||
if (!list.includes(current)) return [current, ...list]
|
if (!list.includes(current)) return [current, ...list]
|
||||||
return [current, ...list.filter((x) => x !== current)]
|
return [current, ...list.filter((item) => item !== current)]
|
||||||
})
|
})
|
||||||
|
const health = useServerHealth(servers, fetcher)
|
||||||
const sortedServers = createMemo(() => {
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
|
||||||
const list = servers()
|
const mcp = useMcpToggle({ sync, sdk, language })
|
||||||
if (!list.length) return list
|
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
|
||||||
const active = server.url
|
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
|
||||||
const rank = (value?: ServerHealth) => {
|
|
||||||
if (value?.healthy === true) return 0
|
|
||||||
if (value?.healthy === false) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
if (a === active) return -1
|
|
||||||
if (b === active) return 1
|
|
||||||
const diff = rank(store.status[a]) - rank(store.status[b])
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function refreshHealth() {
|
|
||||||
const results: Record<string, ServerHealth> = {}
|
|
||||||
await Promise.all(
|
|
||||||
servers().map(async (url) => {
|
|
||||||
results[url] = await checkServerHealth(url, fetcher)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setStore("status", reconcile(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
servers()
|
|
||||||
refreshHealth()
|
|
||||||
const interval = setInterval(refreshHealth, 10_000)
|
|
||||||
onCleanup(() => clearInterval(interval))
|
|
||||||
})
|
|
||||||
|
|
||||||
const mcpItems = createMemo(() =>
|
const mcpItems = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
.map(([name, status]) => ({ name, status: status.status }))
|
.map(([name, status]) => ({ name, status: status.status }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length)
|
||||||
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
|
|
||||||
|
|
||||||
const toggleMcp = async (name: string) => {
|
|
||||||
if (store.loading) return
|
|
||||||
setStore("loading", name)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = sync.data.mcp[name]
|
|
||||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
|
||||||
const result = await sdk.client.mcp.status()
|
|
||||||
if (result.data) sync.set("mcp", result.data)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setStore("loading", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||||
const pluginCount = createMemo(() => plugins().length)
|
const pluginCount = createMemo(() => plugins().length)
|
||||||
|
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||||
const overallHealthy = createMemo(() => {
|
const overallHealthy = createMemo(() => {
|
||||||
const serverHealthy = server.healthy() === true
|
const serverHealthy = server.healthy() === true
|
||||||
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
|
const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||||
return serverHealthy && !anyMcpIssue
|
return serverHealthy && !anyMcpIssue
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverCount = createMemo(() => sortedServers().length)
|
|
||||||
|
|
||||||
const refreshDefaultServerUrl = () => {
|
|
||||||
const result = platform.getDefaultServerUrl?.()
|
|
||||||
if (!result) {
|
|
||||||
setStore("defaultServerUrl", undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("defaultServerUrl", normalizeServerUrl(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
refreshDefaultServerUrl()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
@@ -173,7 +228,7 @@ export function StatusPopover() {
|
|||||||
>
|
>
|
||||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||||
{serverCount() > 0 ? `${serverCount()} ` : ""}
|
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||||
{language.t("status.popover.tab.servers")}
|
{language.t("status.popover.tab.servers")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||||
@@ -195,11 +250,7 @@ export function StatusPopover() {
|
|||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
<For each={sortedServers()}>
|
<For each={sortedServers()}>
|
||||||
{(url) => {
|
{(url) => {
|
||||||
const isActive = () => url === server.url
|
const isBlocked = () => health[url]?.healthy === false
|
||||||
const isDefault = () => url === store.defaultServerUrl
|
|
||||||
const status = () => store.status[url]
|
|
||||||
const isBlocked = () => status()?.healthy === false
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -217,13 +268,13 @@ export function StatusPopover() {
|
|||||||
>
|
>
|
||||||
<ServerRow
|
<ServerRow
|
||||||
url={url}
|
url={url}
|
||||||
status={status()}
|
status={health[url]}
|
||||||
dimmed={isBlocked()}
|
dimmed={isBlocked()}
|
||||||
class="flex items-center gap-2 w-full min-w-0"
|
class="flex items-center gap-2 w-full min-w-0"
|
||||||
nameClass="text-14-regular text-text-base truncate"
|
nameClass="text-14-regular text-text-base truncate"
|
||||||
versionClass="text-12-regular text-text-weak truncate"
|
versionClass="text-12-regular text-text-weak truncate"
|
||||||
badge={
|
badge={
|
||||||
<Show when={isDefault()}>
|
<Show when={url === defaultServer.url()}>
|
||||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||||
{language.t("common.default")}
|
{language.t("common.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -231,7 +282,7 @@ export function StatusPopover() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
<Show when={isActive()}>
|
<Show when={url === server.url}>
|
||||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||||
</Show>
|
</Show>
|
||||||
</ServerRow>
|
</ServerRow>
|
||||||
@@ -243,7 +294,7 @@ export function StatusPopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
|
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
||||||
>
|
>
|
||||||
{language.t("status.popover.action.manageServers")}
|
{language.t("status.popover.action.manageServers")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -269,8 +320,8 @@ export function StatusPopover() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
onClick={() => toggleMcp(item.name)}
|
onClick={() => mcp.toggle(item.name)}
|
||||||
disabled={store.loading === item.name}
|
disabled={mcp.loading() === item.name}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -286,8 +337,8 @@ export function StatusPopover() {
|
|||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={store.loading === item.name}
|
disabled={mcp.loading() === item.name}
|
||||||
onChange={() => toggleMcp(item.name)}
|
onChange={() => mcp.toggle(item.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -334,23 +385,7 @@ export function StatusPopover() {
|
|||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
<Show
|
<Show
|
||||||
when={plugins().length > 0}
|
when={plugins().length > 0}
|
||||||
fallback={
|
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||||
<div class="text-14-regular text-text-base text-center my-auto">
|
|
||||||
{(() => {
|
|
||||||
const value = language.t("dialog.plugins.empty")
|
|
||||||
const file = "opencode.json"
|
|
||||||
const parts = value.split(file)
|
|
||||||
if (parts.length === 1) return value
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{parts[0]}
|
|
||||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
|
||||||
{parts.slice(1).join(file)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<For each={plugins()}>
|
<For each={plugins()}>
|
||||||
{(plugin) => (
|
{(plugin) => (
|
||||||
|
|||||||
@@ -56,6 +56,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugTerminal = (...values: unknown[]) => {
|
||||||
|
if (!import.meta.env.DEV) return
|
||||||
|
console.debug("[terminal]", ...values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useTerminalUiBindings = (input: {
|
||||||
|
container: HTMLDivElement
|
||||||
|
term: Term
|
||||||
|
cleanups: VoidFunction[]
|
||||||
|
handlePointerDown: () => void
|
||||||
|
handleLinkClick: (event: MouseEvent) => void
|
||||||
|
}) => {
|
||||||
|
const handleCopy = (event: ClipboardEvent) => {
|
||||||
|
const selection = input.term.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
|
||||||
|
const clipboard = event.clipboardData
|
||||||
|
if (!clipboard) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
clipboard.setData("text/plain", selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
const clipboard = event.clipboardData
|
||||||
|
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
|
||||||
|
if (!text) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
input.term.paste(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTextareaFocus = () => {
|
||||||
|
input.term.options.cursorBlink = true
|
||||||
|
}
|
||||||
|
const handleTextareaBlur = () => {
|
||||||
|
input.term.options.cursorBlink = false
|
||||||
|
}
|
||||||
|
|
||||||
|
input.container.addEventListener("copy", handleCopy, true)
|
||||||
|
input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
|
||||||
|
|
||||||
|
input.container.addEventListener("paste", handlePaste, true)
|
||||||
|
input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
|
||||||
|
|
||||||
|
input.container.addEventListener("pointerdown", input.handlePointerDown)
|
||||||
|
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
|
||||||
|
|
||||||
|
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
|
||||||
|
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
|
||||||
|
|
||||||
|
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
|
||||||
|
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
|
||||||
|
input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
|
||||||
|
input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistTerminal = (input: {
|
||||||
|
term: Term | undefined
|
||||||
|
addon: SerializeAddon | undefined
|
||||||
|
cursor: number
|
||||||
|
pty: LocalPTY
|
||||||
|
onCleanup?: (pty: LocalPTY) => void
|
||||||
|
}) => {
|
||||||
|
if (!input.addon || !input.onCleanup || !input.term) return
|
||||||
|
const buffer = (() => {
|
||||||
|
try {
|
||||||
|
return input.addon.serialize()
|
||||||
|
} catch {
|
||||||
|
debugTerminal("failed to serialize terminal buffer")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
input.onCleanup({
|
||||||
|
...input.pty,
|
||||||
|
buffer,
|
||||||
|
cursor: input.cursor,
|
||||||
|
rows: input.term.rows,
|
||||||
|
cols: input.term.cols,
|
||||||
|
scrollY: input.term.getViewportY(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const Terminal = (props: TerminalProps) => {
|
export const Terminal = (props: TerminalProps) => {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -70,8 +155,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
let handleTextareaFocus: () => void
|
|
||||||
let handleTextareaBlur: () => void
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
const cleanups: VoidFunction[] = []
|
const cleanups: VoidFunction[] = []
|
||||||
const start =
|
const start =
|
||||||
@@ -84,12 +167,23 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
for (const fn of fns) {
|
for (const fn of fns) {
|
||||||
try {
|
try {
|
||||||
fn()
|
fn()
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
debugTerminal("cleanup failed", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pushSize = (cols: number, rows: number) => {
|
||||||
|
return sdk.client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: local.pty.id,
|
||||||
|
size: { cols, rows },
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
debugTerminal("failed to sync terminal size", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getTerminalColors = (): TerminalColors => {
|
const getTerminalColors = (): TerminalColors => {
|
||||||
const mode = theme.mode() === "dark" ? "dark" : "light"
|
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||||
const fallback = DEFAULT_TERMINAL_COLORS[mode]
|
const fallback = DEFAULT_TERMINAL_COLORS[mode]
|
||||||
@@ -219,27 +313,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
ghostty = g
|
ghostty = g
|
||||||
term = t
|
term = t
|
||||||
|
|
||||||
const handleCopy = (event: ClipboardEvent) => {
|
|
||||||
const selection = t.getSelection()
|
|
||||||
if (!selection) return
|
|
||||||
|
|
||||||
const clipboard = event.clipboardData
|
|
||||||
if (!clipboard) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
clipboard.setData("text/plain", selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = (event: ClipboardEvent) => {
|
|
||||||
const clipboard = event.clipboardData
|
|
||||||
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
|
|
||||||
if (!text) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
t.paste(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.attachCustomKeyEventHandler((event) => {
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
|
|
||||||
@@ -255,12 +328,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
return matchKeybind(keybinds, event)
|
return matchKeybind(keybinds, event)
|
||||||
})
|
})
|
||||||
|
|
||||||
container.addEventListener("copy", handleCopy, true)
|
|
||||||
cleanups.push(() => container.removeEventListener("copy", handleCopy, true))
|
|
||||||
|
|
||||||
container.addEventListener("paste", handlePaste, true)
|
|
||||||
cleanups.push(() => container.removeEventListener("paste", handlePaste, true))
|
|
||||||
|
|
||||||
const fit = new mod.FitAddon()
|
const fit = new mod.FitAddon()
|
||||||
const serializer = new SerializeAddon()
|
const serializer = new SerializeAddon()
|
||||||
cleanups.push(() => disposeIfDisposable(fit))
|
cleanups.push(() => disposeIfDisposable(fit))
|
||||||
@@ -270,24 +337,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
serializeAddon = serializer
|
serializeAddon = serializer
|
||||||
|
|
||||||
t.open(container)
|
t.open(container)
|
||||||
|
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
|
||||||
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
|
||||||
|
|
||||||
container.addEventListener("click", handleLinkClick, { capture: true })
|
|
||||||
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
|
|
||||||
|
|
||||||
handleTextareaFocus = () => {
|
|
||||||
t.options.cursorBlink = true
|
|
||||||
}
|
|
||||||
handleTextareaBlur = () => {
|
|
||||||
t.options.cursorBlink = false
|
|
||||||
}
|
|
||||||
|
|
||||||
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
|
||||||
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
|
||||||
cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
|
|
||||||
cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
|
|
||||||
|
|
||||||
focusTerminal()
|
focusTerminal()
|
||||||
|
|
||||||
@@ -316,15 +366,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const onResize = t.onResize(async (size) => {
|
const onResize = t.onResize(async (size) => {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
await sdk.client.pty
|
await pushSize(size.cols, size.rows)
|
||||||
.update({
|
|
||||||
ptyID: local.pty.id,
|
|
||||||
size: {
|
|
||||||
cols: size.cols,
|
|
||||||
rows: size.rows,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
cleanups.push(() => disposeIfDisposable(onResize))
|
cleanups.push(() => disposeIfDisposable(onResize))
|
||||||
@@ -346,15 +388,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
sdk.client.pty
|
void pushSize(t.cols, t.rows)
|
||||||
.update({
|
|
||||||
ptyID: local.pty.id,
|
|
||||||
size: {
|
|
||||||
cols: t.cols,
|
|
||||||
rows: t.rows,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
}
|
||||||
socket.addEventListener("open", handleOpen)
|
socket.addEventListener("open", handleOpen)
|
||||||
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
||||||
@@ -374,8 +408,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||||
cursor = next
|
cursor = next
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
debugTerminal("invalid websocket control frame", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -425,25 +459,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
const t = term
|
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||||
if (serializeAddon && props.onCleanup && t) {
|
|
||||||
const buffer = (() => {
|
|
||||||
try {
|
|
||||||
return serializeAddon.serialize()
|
|
||||||
} catch {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
props.onCleanup({
|
|
||||||
...local.pty,
|
|
||||||
buffer,
|
|
||||||
cursor,
|
|
||||||
rows: t.rows,
|
|
||||||
cols: t.cols,
|
|
||||||
scrollY: t.getViewportY(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,28 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
|
|
||||||
|
type TauriDesktopWindow = {
|
||||||
|
startDragging?: () => Promise<void>
|
||||||
|
toggleMaximize?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TauriThemeWindow = {
|
||||||
|
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TauriApi = {
|
||||||
|
window?: {
|
||||||
|
getCurrentWindow?: () => TauriDesktopWindow
|
||||||
|
}
|
||||||
|
webviewWindow?: {
|
||||||
|
getCurrentWebviewWindow?: () => TauriThemeWindow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
|
||||||
|
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
|
||||||
|
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
|
||||||
|
|
||||||
export function Titlebar() {
|
export function Titlebar() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -82,22 +104,7 @@ export function Titlebar() {
|
|||||||
|
|
||||||
const getWin = () => {
|
const getWin = () => {
|
||||||
if (platform.platform !== "desktop") return
|
if (platform.platform !== "desktop") return
|
||||||
|
return currentDesktopWindow()
|
||||||
const tauri = (
|
|
||||||
window as unknown as {
|
|
||||||
__TAURI__?: {
|
|
||||||
window?: {
|
|
||||||
getCurrentWindow?: () => {
|
|
||||||
startDragging?: () => Promise<void>
|
|
||||||
toggleMaximize?: () => Promise<void>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).__TAURI__
|
|
||||||
if (!tauri?.window?.getCurrentWindow) return
|
|
||||||
|
|
||||||
return tauri.window.getCurrentWindow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -106,13 +113,8 @@ export function Titlebar() {
|
|||||||
const scheme = theme.colorScheme()
|
const scheme = theme.colorScheme()
|
||||||
const value = scheme === "system" ? null : scheme
|
const value = scheme === "system" ? null : scheme
|
||||||
|
|
||||||
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
|
const win = currentThemeWindow()
|
||||||
.__TAURI__
|
if (!win?.setTheme) return
|
||||||
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
|
|
||||||
if (!get) return
|
|
||||||
|
|
||||||
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
|
|
||||||
if (!win.setTheme) return
|
|
||||||
|
|
||||||
void win.setTheme(value).catch(() => undefined)
|
void win.setTheme(value).catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
|
|||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||||
const SUGGESTED_PREFIX = "suggested."
|
const SUGGESTED_PREFIX = "suggested."
|
||||||
|
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
|
||||||
|
|
||||||
function actionId(id: string) {
|
function actionId(id: string) {
|
||||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||||
@@ -33,6 +34,11 @@ function signatureFromEvent(event: KeyboardEvent) {
|
|||||||
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
|
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAllowedEditableKeybind(id: string | undefined) {
|
||||||
|
if (!id) return false
|
||||||
|
return EDITABLE_KEYBIND_IDS.has(actionId(id))
|
||||||
|
}
|
||||||
|
|
||||||
export type KeybindConfig = string
|
export type KeybindConfig = string
|
||||||
|
|
||||||
export interface Keybind {
|
export interface Keybind {
|
||||||
@@ -56,6 +62,8 @@ export interface CommandOption {
|
|||||||
onHighlight?: () => (() => void) | void
|
onHighlight?: () => (() => void) | void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CommandSource = "palette" | "keybind" | "slash"
|
||||||
|
|
||||||
export type CommandCatalogItem = {
|
export type CommandCatalogItem = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
@@ -169,6 +177,14 @@ export function formatKeybind(config: string): string {
|
|||||||
return IS_MAC ? parts.join("") : parts.join("+")
|
return IS_MAC ? parts.join("") : parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false
|
||||||
|
if (target.isContentEditable) return true
|
||||||
|
if (target.closest("[contenteditable='true']")) return true
|
||||||
|
if (target.closest("input, textarea, select")) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||||
name: "Command",
|
name: "Command",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
|
const optionMap = createMemo(() => {
|
||||||
|
const map = new Map<string, CommandOption>()
|
||||||
for (const option of options()) {
|
for (const option of options()) {
|
||||||
if (option.id === id || option.id === "suggested." + id) {
|
map.set(option.id, option)
|
||||||
option.onSelect?.(source)
|
map.set(actionId(option.id), option)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = (id: string, source?: CommandSource) => {
|
||||||
|
const option = optionMap().get(id)
|
||||||
|
option?.onSelect?.(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showPalette = () => {
|
const showPalette = () => {
|
||||||
@@ -292,14 +313,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
if (suspended() || dialog.active) return
|
if (suspended() || dialog.active) return
|
||||||
|
|
||||||
const sig = signatureFromEvent(event)
|
const sig = signatureFromEvent(event)
|
||||||
|
const isPalette = palette().has(sig)
|
||||||
|
const option = keymap().get(sig)
|
||||||
|
|
||||||
if (palette().has(sig)) {
|
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
|
||||||
|
|
||||||
|
if (isPalette) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
showPalette()
|
showPalette()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const option = keymap().get(sig)
|
|
||||||
if (!option) return
|
if (!option) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
option.onSelect?.("keybind")
|
option.onSelect?.("keybind")
|
||||||
@@ -332,7 +356,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
trigger(id: string, source?: CommandSource) {
|
||||||
run(id, source)
|
run(id, source)
|
||||||
},
|
},
|
||||||
keybind(id: string) {
|
keybind(id: string) {
|
||||||
@@ -351,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
},
|
},
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
|
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
|
||||||
},
|
},
|
||||||
suspended,
|
suspended,
|
||||||
get catalog() {
|
get catalog() {
|
||||||
|
|||||||
@@ -109,4 +109,45 @@ describe("comments session indexing", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("remove keeps focus when same comment id exists in another file", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const comments = createCommentSessionForTest({
|
||||||
|
"a.ts": [line("a.ts", "shared", 10)],
|
||||||
|
"b.ts": [line("b.ts", "shared", 20)],
|
||||||
|
})
|
||||||
|
|
||||||
|
comments.setFocus({ file: "b.ts", id: "shared" })
|
||||||
|
comments.remove("a.ts", "shared")
|
||||||
|
|
||||||
|
expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
|
||||||
|
expect(comments.list("a.ts")).toEqual([])
|
||||||
|
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("setFocus and setActive updater callbacks receive current state", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const comments = createCommentSessionForTest()
|
||||||
|
|
||||||
|
comments.setFocus({ file: "a.ts", id: "a1" })
|
||||||
|
comments.setFocus((current) => {
|
||||||
|
expect(current).toEqual({ file: "a.ts", id: "a1" })
|
||||||
|
return { file: "b.ts", id: "b1" }
|
||||||
|
})
|
||||||
|
|
||||||
|
comments.setActive({ file: "c.ts", id: "c1" })
|
||||||
|
comments.setActive((current) => {
|
||||||
|
expect(current).toEqual({ file: "c.ts", id: "c1" })
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
|
||||||
|
expect(comments.active()).toBeNull()
|
||||||
|
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -20,6 +20,19 @@ type CommentFocus = { file: string; id: string }
|
|||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_COMMENT_SESSIONS = 20
|
const MAX_COMMENT_SESSIONS = 20
|
||||||
|
|
||||||
|
function sessionKey(dir: string, id: string | undefined) {
|
||||||
|
return `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSessionKey(key: string) {
|
||||||
|
const split = key.lastIndexOf("\n")
|
||||||
|
if (split < 0) return { dir: key, id: WORKSPACE_KEY }
|
||||||
|
return {
|
||||||
|
dir: key.slice(0, split),
|
||||||
|
id: key.slice(split + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type CommentStore = {
|
type CommentStore = {
|
||||||
comments: Record<string, LineComment[]>
|
comments: Record<string, LineComment[]>
|
||||||
}
|
}
|
||||||
@@ -31,24 +44,24 @@ function aggregate(comments: Record<string, LineComment[]>) {
|
|||||||
.sort((a, b) => a.time - b.time)
|
.sort((a, b) => a.time - b.time)
|
||||||
}
|
}
|
||||||
|
|
||||||
function insert(items: LineComment[], next: LineComment) {
|
|
||||||
const index = items.findIndex((item) => item.time > next.time)
|
|
||||||
if (index < 0) return [...items, next]
|
|
||||||
return [...items.slice(0, index), next, ...items.slice(index)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
focus: null as CommentFocus | null,
|
focus: null as CommentFocus | null,
|
||||||
active: null as CommentFocus | null,
|
active: null as CommentFocus | null,
|
||||||
all: aggregate(store.comments),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const all = () => aggregate(store.comments)
|
||||||
|
|
||||||
|
const setRef = (
|
||||||
|
key: "focus" | "active",
|
||||||
|
value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
|
||||||
|
) => setState(key, value)
|
||||||
|
|
||||||
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||||
setState("focus", value)
|
setRef("focus", value)
|
||||||
|
|
||||||
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
|
||||||
setState("active", value)
|
setRef("active", value)
|
||||||
|
|
||||||
const list = (file: string) => store.comments[file] ?? []
|
const list = (file: string) => store.comments[file] ?? []
|
||||||
|
|
||||||
@@ -61,7 +74,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
|||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("comments", input.file, (items) => [...(items ?? []), next])
|
setStore("comments", input.file, (items) => [...(items ?? []), next])
|
||||||
setState("all", (items) => insert(items, next))
|
|
||||||
setFocus({ file: input.file, id: next.id })
|
setFocus({ file: input.file, id: next.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -71,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
|||||||
const remove = (file: string, id: string) => {
|
const remove = (file: string, id: string) => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
|
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
|
||||||
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
|
setFocus((current) => (current?.file === file && current.id === id ? null : current))
|
||||||
setFocus((current) => (current?.id === id ? null : current))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("comments", reconcile({}))
|
setStore("comments", reconcile({}))
|
||||||
setState("all", [])
|
|
||||||
setFocus(null)
|
setFocus(null)
|
||||||
setActive(null)
|
setActive(null)
|
||||||
})
|
})
|
||||||
@@ -87,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
all: () => state.all,
|
all,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
clear,
|
clear,
|
||||||
focus: () => state.focus,
|
focus: () => state.focus,
|
||||||
setFocus,
|
setFocus,
|
||||||
clearFocus: () => setFocus(null),
|
clearFocus: () => setRef("focus", null),
|
||||||
active: () => state.active,
|
active: () => state.active,
|
||||||
setActive,
|
setActive,
|
||||||
clearActive: () => setActive(null),
|
clearActive: () => setRef("active", null),
|
||||||
reindex: () => setState("all", aggregate(store.comments)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) {
|
|||||||
)
|
)
|
||||||
const session = createCommentSessionState(store, setStore)
|
const session = createCommentSessionState(store, setStore)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
session.reindex()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
list: session.list,
|
list: session.list,
|
||||||
@@ -145,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const cache = createScopedCache(
|
const cache = createScopedCache(
|
||||||
(key) => {
|
(key) => {
|
||||||
const split = key.lastIndexOf("\n")
|
const decoded = decodeSessionKey(key)
|
||||||
const dir = split >= 0 ? key.slice(0, split) : key
|
|
||||||
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
|
|
||||||
return createRoot((dispose) => ({
|
return createRoot((dispose) => ({
|
||||||
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
|
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
@@ -162,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
|||||||
onCleanup(() => cache.clear())
|
onCleanup(() => cache.clear())
|
||||||
|
|
||||||
const load = (dir: string, id: string | undefined) => {
|
const load = (dir: string, id: string | undefined) => {
|
||||||
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
const key = sessionKey(dir, id)
|
||||||
return cache.get(key).value
|
return cache.get(key).value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export {
|
|||||||
touchFileContent,
|
touchFileContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error && error.message) return error.message
|
||||||
|
if (typeof error === "string" && error) return error
|
||||||
|
return "Unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
name: "File",
|
name: "File",
|
||||||
gate: false,
|
gate: false,
|
||||||
@@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
setStore("file", file, { path: file, name: getFilename(file) })
|
setStore("file", file, { path: file, name: getFilename(file) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setLoading = (file: string) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
file,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = true
|
||||||
|
draft.error = undefined
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLoaded = (file: string, content: FileState["content"]) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
file,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loaded = true
|
||||||
|
draft.loading = false
|
||||||
|
draft.content = content
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLoadError = (file: string, message: string) => {
|
||||||
|
setStore(
|
||||||
|
"file",
|
||||||
|
file,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.loading = false
|
||||||
|
draft.error = message
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.file.loadFailed.title"),
|
||||||
|
description: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const load = (input: string, options?: { force?: boolean }) => {
|
const load = (input: string, options?: { force?: boolean }) => {
|
||||||
const file = path.normalize(input)
|
const file = path.normalize(input)
|
||||||
if (!file) return Promise.resolve()
|
if (!file) return Promise.resolve()
|
||||||
@@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
const pending = inflight.get(key)
|
const pending = inflight.get(key)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
setStore(
|
setLoading(file)
|
||||||
"file",
|
|
||||||
file,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.loading = true
|
|
||||||
draft.error = undefined
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const promise = sdk.client.file
|
const promise = sdk.client.file
|
||||||
.read({ path: file })
|
.read({ path: file })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
const content = x.data
|
const content = x.data
|
||||||
setStore(
|
setLoaded(file, content)
|
||||||
"file",
|
|
||||||
file,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.loaded = true
|
|
||||||
draft.loading = false
|
|
||||||
draft.content = content
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!content) return
|
if (!content) return
|
||||||
touchFileContent(file, approxBytes(content))
|
touchFileContent(file, approxBytes(content))
|
||||||
@@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
setStore(
|
setLoadError(file, errorMessage(e))
|
||||||
"file",
|
|
||||||
file,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.loading = false
|
|
||||||
draft.error = e.message
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("toast.file.loadFailed.title"),
|
|
||||||
description: e.message,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inflight.delete(key)
|
inflight.delete(key)
|
||||||
@@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
|
function withPath(input: string, action: (file: string) => unknown) {
|
||||||
const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
|
return action(path.normalize(input))
|
||||||
const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
|
|
||||||
|
|
||||||
const setScrollTop = (input: string, top: number) => {
|
|
||||||
view().setScrollTop(path.normalize(input), top)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setScrollLeft = (input: string, left: number) => {
|
|
||||||
view().setScrollLeft(path.normalize(input), left)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
|
|
||||||
view().setSelectedLines(path.normalize(input), range)
|
|
||||||
}
|
}
|
||||||
|
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
|
||||||
|
const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file))
|
||||||
|
const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file))
|
||||||
|
const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top))
|
||||||
|
const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left))
|
||||||
|
const setSelectedLines = (input: string, range: SelectedLineRange | null) =>
|
||||||
|
withPath(input, (file) => view().setSelectedLines(file, range))
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
stop()
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Queued = { directory: string; payload: Event }
|
type Queued = { directory: string; payload: Event }
|
||||||
|
const FLUSH_FRAME_MS = 16
|
||||||
|
const STREAM_YIELD_MS = 8
|
||||||
|
|
||||||
let queue: Array<Queued | undefined> = []
|
let queue: Queued[] = []
|
||||||
let buffer: Array<Queued | undefined> = []
|
let buffer: Queued[] = []
|
||||||
const coalesced = new Map<string, number>()
|
const coalesced = new Map<string, number>()
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
@@ -62,7 +64,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
last = Date.now()
|
last = Date.now()
|
||||||
batch(() => {
|
batch(() => {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (!event) continue
|
|
||||||
emitter.emit(event.directory, event.payload)
|
emitter.emit(event.directory, event.payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -73,9 +74,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
if (timer) return
|
if (timer) return
|
||||||
const elapsed = Date.now() - last
|
const elapsed = Date.now() - last
|
||||||
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let streamErrorLogged = false
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const events = await eventSdk.global.event()
|
const events = await eventSdk.global.event()
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
@@ -86,20 +89,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
if (k) {
|
if (k) {
|
||||||
const i = coalesced.get(k)
|
const i = coalesced.get(k)
|
||||||
if (i !== undefined) {
|
if (i !== undefined) {
|
||||||
queue[i] = undefined
|
queue[i] = { directory, payload }
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
coalesced.set(k, queue.length)
|
coalesced.set(k, queue.length)
|
||||||
}
|
}
|
||||||
queue.push({ directory, payload })
|
queue.push({ directory, payload })
|
||||||
schedule()
|
schedule()
|
||||||
|
|
||||||
if (Date.now() - yielded < 8) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
.finally(flush)
|
.finally(flush)
|
||||||
.catch(() => undefined)
|
.catch((error) => {
|
||||||
|
if (streamErrorLogged) return
|
||||||
|
streamErrorLogged = true
|
||||||
|
console.error("[global-sdk] event stream failed", error)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ type GlobalStore = {
|
|||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error && error.message) return error.message
|
||||||
|
if (typeof error === "string" && error) return error
|
||||||
|
return "Unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDevStats(value: {
|
||||||
|
activeDirectoryStores: number
|
||||||
|
evictions: number
|
||||||
|
loadSessionsFullFetchFallback: number
|
||||||
|
}) {
|
||||||
|
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
|
||||||
|
}
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
@@ -81,19 +95,11 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
const updateStats = (activeDirectoryStores: number) => {
|
const updateStats = (activeDirectoryStores: number) => {
|
||||||
if (!import.meta.env.DEV) return
|
if (!import.meta.env.DEV) return
|
||||||
;(
|
setDevStats({
|
||||||
globalThis as {
|
|
||||||
__OPENCODE_GLOBAL_SYNC_STATS?: {
|
|
||||||
activeDirectoryStores: number
|
|
||||||
evictions: number
|
|
||||||
loadSessionsFullFetchFallback: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).__OPENCODE_GLOBAL_SYNC_STATS = {
|
|
||||||
activeDirectoryStores,
|
activeDirectoryStores,
|
||||||
evictions: stats.evictions,
|
evictions: stats.evictions,
|
||||||
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||||
@@ -204,7 +210,10 @@ function createGlobalSync() {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Failed to load sessions", err)
|
console.error("Failed to load sessions", err)
|
||||||
const project = getFilename(directory)
|
const project = getFilename(directory)
|
||||||
showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
|
showToast({
|
||||||
|
title: language.t("toast.session.listFailed.title", { project }),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
sessionLoads.set(directory, promise)
|
sessionLoads.set(directory, promise)
|
||||||
@@ -307,12 +316,28 @@ function createGlobalSync() {
|
|||||||
void bootstrap()
|
void bootstrap()
|
||||||
})
|
})
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
const projectApi = {
|
||||||
children.projectMeta(directory, patch)
|
loadSessions,
|
||||||
|
meta(directory: string, patch: ProjectMeta) {
|
||||||
|
children.projectMeta(directory, patch)
|
||||||
|
},
|
||||||
|
icon(directory: string, value: string | undefined) {
|
||||||
|
children.projectIcon(directory, value)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectIcon(directory: string, value: string | undefined) {
|
const updateConfig = async (config: Config) => {
|
||||||
children.projectIcon(directory, value)
|
setGlobalStore("reload", "pending")
|
||||||
|
return globalSDK.client.global.config
|
||||||
|
.update({ config })
|
||||||
|
.then(bootstrap)
|
||||||
|
.then(() => {
|
||||||
|
setGlobalStore("reload", "complete")
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setGlobalStore("reload", undefined)
|
||||||
|
throw error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -326,19 +351,8 @@ function createGlobalSync() {
|
|||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
updateConfig: (config: Config) => {
|
updateConfig,
|
||||||
setGlobalStore("reload", "pending")
|
project: projectApi,
|
||||||
return globalSDK.client.global.config.update({ config }).finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
setGlobalStore("reload", "complete")
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
project: {
|
|
||||||
loadSessions,
|
|
||||||
meta: projectMeta,
|
|
||||||
icon: projectIcon,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
|
|||||||
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
|
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const unique = highlights.filter((highlight) => {
|
const unique = highlights.filter((highlight) => {
|
||||||
const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
|
const key = dedupeKey(highlight)
|
||||||
"\n",
|
|
||||||
)
|
|
||||||
if (seen.has(key)) return false
|
if (seen.has(key)) return false
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
return true
|
return true
|
||||||
@@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
|
|||||||
return unique.slice(0, 5)
|
return unique.slice(0, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupeKey(highlight: Highlight) {
|
||||||
|
return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
|
||||||
|
const releases = parseChangelog(value)
|
||||||
|
if (!releases?.length) return []
|
||||||
|
return sliceHighlights({ releases, current, previous })
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
|
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
|
||||||
name: "Highlights",
|
name: "Highlights",
|
||||||
gate: false,
|
gate: false,
|
||||||
@@ -140,14 +148,57 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
|
|
||||||
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
||||||
const [to, setTo] = createSignal<string | undefined>(undefined)
|
const [to, setTo] = createSignal<string | undefined>(undefined)
|
||||||
const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
||||||
const state = { started: false }
|
const state = { started: false }
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer === undefined) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const markSeen = () => {
|
const markSeen = () => {
|
||||||
if (!platform.version) return
|
if (!platform.version) return
|
||||||
setStore("version", platform.version)
|
setStore("version", platform.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const start = (previous: string) => {
|
||||||
|
if (!settings.general.releaseNotes()) {
|
||||||
|
markSeen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = platform.fetch ?? fetch
|
||||||
|
const controller = new AbortController()
|
||||||
|
onCleanup(() => {
|
||||||
|
controller.abort()
|
||||||
|
clearTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
fetcher(CHANGELOG_URL, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
})
|
||||||
|
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
||||||
|
.then((json) => {
|
||||||
|
if (!json) return
|
||||||
|
const highlights = loadReleaseHighlights(json, platform.version, previous)
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
|
||||||
|
if (highlights.length === 0) {
|
||||||
|
markSeen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = undefined
|
||||||
|
markSeen()
|
||||||
|
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (state.started) return
|
if (state.started) return
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
@@ -165,51 +216,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
|
|||||||
|
|
||||||
setFrom(previous)
|
setFrom(previous)
|
||||||
setTo(platform.version)
|
setTo(platform.version)
|
||||||
|
start(previous)
|
||||||
if (!settings.general.releaseNotes()) {
|
|
||||||
markSeen()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? fetch
|
|
||||||
const controller = new AbortController()
|
|
||||||
onCleanup(() => {
|
|
||||||
controller.abort()
|
|
||||||
const id = timer()
|
|
||||||
if (id === undefined) return
|
|
||||||
clearTimeout(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
fetcher(CHANGELOG_URL, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
})
|
|
||||||
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
|
||||||
.then((json) => {
|
|
||||||
if (!json) return
|
|
||||||
const releases = parseChangelog(json)
|
|
||||||
if (!releases) return
|
|
||||||
if (releases.length === 0) return
|
|
||||||
const highlights = sliceHighlights({
|
|
||||||
releases,
|
|
||||||
current: platform.version,
|
|
||||||
previous,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (controller.signal.aborted) return
|
|
||||||
|
|
||||||
if (highlights.length === 0) {
|
|
||||||
markSeen()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
markSeen()
|
|
||||||
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
|
||||||
}, 500)
|
|
||||||
setTimer(timer)
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -76,6 +76,66 @@ const LOCALES: readonly Locale[] = [
|
|||||||
"th",
|
"th",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||||
|
en: "language.en",
|
||||||
|
zh: "language.zh",
|
||||||
|
zht: "language.zht",
|
||||||
|
ko: "language.ko",
|
||||||
|
de: "language.de",
|
||||||
|
es: "language.es",
|
||||||
|
fr: "language.fr",
|
||||||
|
da: "language.da",
|
||||||
|
ja: "language.ja",
|
||||||
|
pl: "language.pl",
|
||||||
|
ru: "language.ru",
|
||||||
|
ar: "language.ar",
|
||||||
|
no: "language.no",
|
||||||
|
br: "language.br",
|
||||||
|
th: "language.th",
|
||||||
|
bs: "language.bs",
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = i18n.flatten({ ...en, ...uiEn })
|
||||||
|
const DICT: Record<Locale, Dictionary> = {
|
||||||
|
en: base,
|
||||||
|
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
|
||||||
|
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
|
||||||
|
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
|
||||||
|
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
|
||||||
|
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
|
||||||
|
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
|
||||||
|
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
|
||||||
|
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
|
||||||
|
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
|
||||||
|
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
|
||||||
|
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
|
||||||
|
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
|
||||||
|
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
|
||||||
|
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
|
||||||
|
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||||
|
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||||
|
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||||
|
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||||
|
{ locale: "de", match: (language) => language.startsWith("de") },
|
||||||
|
{ locale: "es", match: (language) => language.startsWith("es") },
|
||||||
|
{ locale: "fr", match: (language) => language.startsWith("fr") },
|
||||||
|
{ locale: "da", match: (language) => language.startsWith("da") },
|
||||||
|
{ locale: "ja", match: (language) => language.startsWith("ja") },
|
||||||
|
{ locale: "pl", match: (language) => language.startsWith("pl") },
|
||||||
|
{ locale: "ru", match: (language) => language.startsWith("ru") },
|
||||||
|
{ locale: "ar", match: (language) => language.startsWith("ar") },
|
||||||
|
{
|
||||||
|
locale: "no",
|
||||||
|
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
|
||||||
|
},
|
||||||
|
{ locale: "br", match: (language) => language.startsWith("pt") },
|
||||||
|
{ locale: "th", match: (language) => language.startsWith("th") },
|
||||||
|
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
||||||
|
]
|
||||||
|
|
||||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||||
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||||
zh,
|
zh,
|
||||||
@@ -102,28 +162,9 @@ function detectLocale(): Locale {
|
|||||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||||
for (const language of languages) {
|
for (const language of languages) {
|
||||||
if (!language) continue
|
if (!language) continue
|
||||||
if (language.toLowerCase().startsWith("zh")) {
|
const normalized = language.toLowerCase()
|
||||||
if (language.toLowerCase().includes("hant")) return "zht"
|
const match = localeMatchers.find((entry) => entry.match(normalized))
|
||||||
return "zh"
|
if (match) return match.locale
|
||||||
}
|
|
||||||
if (language.toLowerCase().startsWith("ko")) return "ko"
|
|
||||||
if (language.toLowerCase().startsWith("de")) return "de"
|
|
||||||
if (language.toLowerCase().startsWith("es")) return "es"
|
|
||||||
if (language.toLowerCase().startsWith("fr")) return "fr"
|
|
||||||
if (language.toLowerCase().startsWith("da")) return "da"
|
|
||||||
if (language.toLowerCase().startsWith("ja")) return "ja"
|
|
||||||
if (language.toLowerCase().startsWith("pl")) return "pl"
|
|
||||||
if (language.toLowerCase().startsWith("ru")) return "ru"
|
|
||||||
if (language.toLowerCase().startsWith("ar")) return "ar"
|
|
||||||
if (
|
|
||||||
language.toLowerCase().startsWith("no") ||
|
|
||||||
language.toLowerCase().startsWith("nb") ||
|
|
||||||
language.toLowerCase().startsWith("nn")
|
|
||||||
)
|
|
||||||
return "no"
|
|
||||||
if (language.toLowerCase().startsWith("pt")) return "br"
|
|
||||||
if (language.toLowerCase().startsWith("th")) return "th"
|
|
||||||
if (language.toLowerCase().startsWith("bs")) return "bs"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "en"
|
return "en"
|
||||||
@@ -139,24 +180,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const locale = createMemo<Locale>(() => {
|
const locale = createMemo<Locale>(() =>
|
||||||
if (store.locale === "zh") return "zh"
|
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
|
||||||
if (store.locale === "zht") return "zht"
|
)
|
||||||
if (store.locale === "ko") return "ko"
|
|
||||||
if (store.locale === "de") return "de"
|
|
||||||
if (store.locale === "es") return "es"
|
|
||||||
if (store.locale === "fr") return "fr"
|
|
||||||
if (store.locale === "da") return "da"
|
|
||||||
if (store.locale === "ja") return "ja"
|
|
||||||
if (store.locale === "pl") return "pl"
|
|
||||||
if (store.locale === "ru") return "ru"
|
|
||||||
if (store.locale === "ar") return "ar"
|
|
||||||
if (store.locale === "no") return "no"
|
|
||||||
if (store.locale === "br") return "br"
|
|
||||||
if (store.locale === "th") return "th"
|
|
||||||
if (store.locale === "bs") return "bs"
|
|
||||||
return "en"
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = locale()
|
const current = locale()
|
||||||
@@ -164,48 +190,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
setStore("locale", current)
|
setStore("locale", current)
|
||||||
})
|
})
|
||||||
|
|
||||||
const base = i18n.flatten({ ...en, ...uiEn })
|
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||||
const dict = createMemo<Dictionary>(() => {
|
|
||||||
if (locale() === "en") return base
|
|
||||||
if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
|
|
||||||
if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }
|
|
||||||
if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) }
|
|
||||||
if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) }
|
|
||||||
if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }
|
|
||||||
if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) }
|
|
||||||
if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }
|
|
||||||
if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }
|
|
||||||
if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }
|
|
||||||
if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
|
|
||||||
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
|
|
||||||
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
|
|
||||||
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
|
|
||||||
if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
|
|
||||||
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
|
|
||||||
})
|
|
||||||
|
|
||||||
const t = i18n.translator(dict, i18n.resolveTemplate)
|
const t = i18n.translator(dict, i18n.resolveTemplate)
|
||||||
|
|
||||||
const labelKey: Record<Locale, keyof Dictionary> = {
|
const label = (value: Locale) => t(LABEL_KEY[value])
|
||||||
en: "language.en",
|
|
||||||
zh: "language.zh",
|
|
||||||
zht: "language.zht",
|
|
||||||
ko: "language.ko",
|
|
||||||
de: "language.de",
|
|
||||||
es: "language.es",
|
|
||||||
fr: "language.fr",
|
|
||||||
da: "language.da",
|
|
||||||
ja: "language.ja",
|
|
||||||
pl: "language.pl",
|
|
||||||
ru: "language.ru",
|
|
||||||
ar: "language.ar",
|
|
||||||
no: "language.no",
|
|
||||||
br: "language.br",
|
|
||||||
th: "language.th",
|
|
||||||
bs: "language.bs",
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = (value: Locale) => t(labelKey[value])
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document !== "object") return
|
if (typeof document !== "object") return
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { same } from "@/utils/same"
|
|||||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
const DEFAULT_PANEL_WIDTH = 344
|
||||||
|
const DEFAULT_SESSION_WIDTH = 600
|
||||||
|
const DEFAULT_TERMINAL_HEIGHT = 280
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||||
|
|
||||||
export function getAvatarColors(key?: string) {
|
export function getAvatarColors(key?: string) {
|
||||||
@@ -85,6 +88,14 @@ export function pruneSessionKeys(input: {
|
|||||||
.slice(input.max)
|
.slice(input.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||||
|
const all = current?.all ?? []
|
||||||
|
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
|
||||||
|
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
|
||||||
|
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
|
||||||
|
return { all, active: tab }
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -116,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (!isRecord(fileTree)) return fileTree
|
if (!isRecord(fileTree)) return fileTree
|
||||||
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
|
||||||
|
|
||||||
const width = typeof fileTree.width === "number" ? fileTree.width : 344
|
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
|
||||||
return {
|
return {
|
||||||
...fileTree,
|
...fileTree,
|
||||||
opened: true,
|
opened: true,
|
||||||
width: width === 260 ? 344 : width,
|
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
|
||||||
tab: "changes",
|
tab: "changes",
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -151,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
width: 344,
|
width: DEFAULT_PANEL_WIDTH,
|
||||||
workspaces: {} as Record<string, boolean>,
|
workspaces: {} as Record<string, boolean>,
|
||||||
workspacesDefault: false,
|
workspacesDefault: false,
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
height: 280,
|
height: DEFAULT_TERMINAL_HEIGHT,
|
||||||
opened: false,
|
opened: false,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
@@ -165,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
fileTree: {
|
fileTree: {
|
||||||
opened: true,
|
opened: true,
|
||||||
width: 344,
|
width: DEFAULT_PANEL_WIDTH,
|
||||||
tab: "changes" as "changes" | "all",
|
tab: "changes" as "changes" | "all",
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
width: 600,
|
width: DEFAULT_SESSION_WIDTH,
|
||||||
},
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
@@ -184,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const MAX_SESSION_KEYS = 50
|
const MAX_SESSION_KEYS = 50
|
||||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||||
const meta = { active: undefined as string | undefined, pruned: false }
|
const usage = {
|
||||||
const used = new Map<string, number>()
|
active: undefined as string | undefined,
|
||||||
|
pruned: false,
|
||||||
|
used: new Map<string, number>(),
|
||||||
|
}
|
||||||
|
|
||||||
const SESSION_STATE_KEYS = [
|
const SESSION_STATE_KEYS = [
|
||||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||||
@@ -214,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
const drop = pruneSessionKeys({
|
const drop = pruneSessionKeys({
|
||||||
keep,
|
keep,
|
||||||
max: MAX_SESSION_KEYS,
|
max: MAX_SESSION_KEYS,
|
||||||
used,
|
used: usage.used,
|
||||||
view: Object.keys(store.sessionView),
|
view: Object.keys(store.sessionView),
|
||||||
tabs: Object.keys(store.sessionTabs),
|
tabs: Object.keys(store.sessionTabs),
|
||||||
})
|
})
|
||||||
@@ -233,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
dropSessionState(drop)
|
dropSessionState(drop)
|
||||||
|
|
||||||
for (const key of drop) {
|
for (const key of drop) {
|
||||||
used.delete(key)
|
usage.used.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function touch(sessionKey: string) {
|
function touch(sessionKey: string) {
|
||||||
meta.active = sessionKey
|
usage.active = sessionKey
|
||||||
used.set(sessionKey, Date.now())
|
usage.used.set(sessionKey, Date.now())
|
||||||
|
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
if (meta.pruned) return
|
if (usage.pruned) return
|
||||||
|
|
||||||
meta.pruned = true
|
usage.pruned = true
|
||||||
prune(sessionKey)
|
prune(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
|
||||||
onFlush: (sessionKey, next) => {
|
onFlush: (sessionKey, next) => {
|
||||||
const current = store.sessionView[sessionKey]
|
const current = store.sessionView[sessionKey]
|
||||||
const keep = meta.active ?? sessionKey
|
const keep = usage.active ?? sessionKey
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("sessionView", sessionKey, { scroll: next })
|
setStore("sessionView", sessionKey, { scroll: next })
|
||||||
prune(keep)
|
prune(keep)
|
||||||
@@ -269,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
if (meta.pruned) return
|
if (usage.pruned) return
|
||||||
const active = meta.active
|
const active = usage.active
|
||||||
if (!active) return
|
if (!active) return
|
||||||
meta.pruned = true
|
usage.pruned = true
|
||||||
prune(active)
|
prune(active)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -546,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
fileTree: {
|
fileTree: {
|
||||||
opened: createMemo(() => store.fileTree?.opened ?? true),
|
opened: createMemo(() => store.fileTree?.opened ?? true),
|
||||||
width: createMemo(() => store.fileTree?.width ?? 344),
|
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
|
||||||
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
||||||
setTab(tab: "changes" | "all") {
|
setTab(tab: "changes" | "all") {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: true, width: 344, tab })
|
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "tab", tab)
|
setStore("fileTree", "tab", tab)
|
||||||
},
|
},
|
||||||
open() {
|
open() {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "opened", true)
|
setStore("fileTree", "opened", true)
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: false, width: 344, tab: "changes" })
|
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "opened", false)
|
setStore("fileTree", "opened", false)
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
if (!store.fileTree) {
|
if (!store.fileTree) {
|
||||||
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("fileTree", "opened", (x) => !x)
|
setStore("fileTree", "opened", (x) => !x)
|
||||||
@@ -585,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
width: createMemo(() => store.session?.width ?? 600),
|
width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
|
||||||
resize(width: number) {
|
resize(width: number) {
|
||||||
if (!store.session) {
|
if (!store.session) {
|
||||||
setStore("session", { width })
|
setStore("session", { width })
|
||||||
@@ -617,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
pendingMessage: messageID,
|
pendingMessage: messageID,
|
||||||
pendingMessageAt: at,
|
pendingMessageAt: at,
|
||||||
})
|
})
|
||||||
prune(meta.active ?? sessionKey)
|
prune(usage.active ?? sessionKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
function setTerminalOpened(next: boolean) {
|
function setTerminalOpened(next: boolean) {
|
||||||
const current = store.terminal
|
const current = store.terminal
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setStore("terminal", { height: 280, opened: next })
|
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const current = store.sessionTabs[session] ?? { all: [] }
|
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
|
||||||
|
setStore("sessionTabs", session, next)
|
||||||
if (tab === "review") {
|
|
||||||
if (!store.sessionTabs[session]) {
|
|
||||||
setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("sessionTabs", session, "active", tab)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === "context") {
|
|
||||||
const all = [tab, ...current.all.filter((x) => x !== tab)]
|
|
||||||
if (!store.sessionTabs[session]) {
|
|
||||||
setStore("sessionTabs", session, { all, active: tab })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("sessionTabs", session, "all", all)
|
|
||||||
setStore("sessionTabs", session, "active", tab)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current.all.includes(tab)) {
|
|
||||||
if (!store.sessionTabs[session]) {
|
|
||||||
setStore("sessionTabs", session, { all: [tab], active: tab })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("sessionTabs", session, "all", [...current.all, tab])
|
|
||||||
setStore("sessionTabs", session, "active", tab)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!store.sessionTabs[session]) {
|
|
||||||
setStore("sessionTabs", session, { all: current.all, active: tab })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore("sessionTabs", session, "active", tab)
|
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
|
|||||||
@@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
|
||||||
|
|
||||||
function isModelValid(model: ModelKey) {
|
function isModelValid(model: ModelKey) {
|
||||||
const provider = providers.all().find((x) => x.id === model.providerID)
|
const provider = providers.all().find((x) => x.id === model.providerID)
|
||||||
return (
|
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||||
!!provider?.models[model.modelID] &&
|
|
||||||
providers
|
|
||||||
.connected()
|
|
||||||
.map((p) => p.id)
|
|
||||||
.includes(model.providerID)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||||
@@ -36,6 +31,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
|
||||||
|
|
||||||
const agent = (() => {
|
const agent = (() => {
|
||||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
if (!value) return
|
if (!value) return
|
||||||
setStore("current", value.name)
|
setStore("current", value.name)
|
||||||
if (value.model)
|
if (value.model)
|
||||||
model.set({
|
setModel({
|
||||||
providerID: value.model.providerID,
|
providerID: value.model.providerID,
|
||||||
modelID: value.model.modelID,
|
modelID: value.model.modelID,
|
||||||
})
|
})
|
||||||
@@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
model: {},
|
model: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
const resolveConfigured = () => {
|
||||||
if (sync.data.config.model) {
|
if (!sync.data.config.model) return
|
||||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||||
if (isModelValid({ providerID, modelID })) {
|
const key = { providerID, modelID }
|
||||||
return {
|
if (isModelValid(key)) return key
|
||||||
providerID,
|
}
|
||||||
modelID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const resolveRecent = () => {
|
||||||
for (const item of models.recent.list()) {
|
for (const item of models.recent.list()) {
|
||||||
if (isModelValid(item)) {
|
if (isModelValid(item)) return item
|
||||||
return item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDefault = () => {
|
||||||
const defaults = providers.default()
|
const defaults = providers.default()
|
||||||
for (const p of providers.connected()) {
|
for (const provider of providers.connected()) {
|
||||||
const configured = defaults[p.id]
|
const configured = defaults[provider.id]
|
||||||
if (configured) {
|
if (configured) {
|
||||||
const key = { providerID: p.id, modelID: configured }
|
const key = { providerID: provider.id, modelID: configured }
|
||||||
if (isModelValid(key)) return key
|
if (isModelValid(key)) return key
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = Object.values(p.models)[0]
|
const first = Object.values(provider.models)[0]
|
||||||
if (!first) continue
|
if (!first) continue
|
||||||
const key = { providerID: p.id, modelID: first.id }
|
const key = { providerID: provider.id, modelID: first.id }
|
||||||
if (isModelValid(key)) return key
|
if (isModelValid(key)) return key
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return undefined
|
const fallbackModel = createMemo<ModelKey | undefined>(() => {
|
||||||
|
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
|
||||||
})
|
})
|
||||||
|
|
||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
@@ -163,21 +159,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
|
||||||
|
batch(() => {
|
||||||
|
const currentAgent = agent.current()
|
||||||
|
const next = model ?? fallbackModel()
|
||||||
|
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
||||||
|
if (model) models.setVisibility(model, true)
|
||||||
|
if (options?.recent && model) models.recent.push(model)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setModel = set
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: models.ready,
|
ready: models.ready,
|
||||||
current,
|
current,
|
||||||
recent,
|
recent,
|
||||||
list: models.list,
|
list: models.list,
|
||||||
cycle,
|
cycle,
|
||||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
set,
|
||||||
batch(() => {
|
|
||||||
const currentAgent = agent.current()
|
|
||||||
const next = model ?? fallbackModel()
|
|
||||||
if (currentAgent) setEphemeral("model", currentAgent.name, next)
|
|
||||||
if (model) models.setVisibility(model, true)
|
|
||||||
if (options?.recent && model) models.recent.push(model)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
visible(model: ModelKey) {
|
visible(model: ModelKey) {
|
||||||
return models.visible(model)
|
return models.visible(model)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ type Store = {
|
|||||||
variant?: Record<string, string | undefined>
|
variant?: Record<string, string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECENT_LIMIT = 5
|
||||||
|
|
||||||
|
function modelKey(model: ModelKey) {
|
||||||
|
return `${model.providerID}:${model.modelID}`
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
|
||||||
name: "Models",
|
name: "Models",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -39,10 +45,27 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const release = createMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
available().map((model) => {
|
||||||
|
const parsed = DateTime.fromISO(model.release_date)
|
||||||
|
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const latest = createMemo(() =>
|
const latest = createMemo(() =>
|
||||||
pipe(
|
pipe(
|
||||||
available(),
|
available(),
|
||||||
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
|
filter(
|
||||||
|
(x) =>
|
||||||
|
Math.abs(
|
||||||
|
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||||
|
.diffNow()
|
||||||
|
.as("months"),
|
||||||
|
) < 6,
|
||||||
|
),
|
||||||
groupBy((x) => x.provider.id),
|
groupBy((x) => x.provider.id),
|
||||||
mapValues((models) =>
|
mapValues((models) =>
|
||||||
pipe(
|
pipe(
|
||||||
@@ -61,7 +84,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
|
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
|
||||||
|
|
||||||
const visibility = createMemo(() => {
|
const visibility = createMemo(() => {
|
||||||
const map = new Map<string, Visibility>()
|
const map = new Map<string, Visibility>()
|
||||||
@@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
|||||||
function update(model: ModelKey, state: Visibility) {
|
function update(model: ModelKey, state: Visibility) {
|
||||||
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
setStore("user", index, { visibility: state })
|
setStore("user", index, (current) => ({ ...current, visibility: state }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("user", store.user.length, { ...model, visibility: state })
|
setStore("user", store.user.length, { ...model, visibility: state })
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = (model: ModelKey) => {
|
const visible = (model: ModelKey) => {
|
||||||
const key = `${model.providerID}:${model.modelID}`
|
const key = modelKey(model)
|
||||||
const state = visibility().get(key)
|
const state = visibility().get(key)
|
||||||
if (state === "hide") return false
|
if (state === "hide") return false
|
||||||
if (state === "show") return true
|
if (state === "show") return true
|
||||||
if (latestSet().has(key)) return true
|
if (latestSet().has(key)) return true
|
||||||
const m = find(model)
|
const date = release().get(key)
|
||||||
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
|
if (!date?.isValid) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const push = (model: ModelKey) => {
|
const push = (model: ModelKey) => {
|
||||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
|
||||||
if (uniq.length > 5) uniq.pop()
|
if (uniq.length > RECENT_LIMIT) uniq.pop()
|
||||||
setStore("recent", uniq)
|
setStore("recent", uniq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { buildNotificationIndex } from "./notification-index"
|
|||||||
type NotificationBase = {
|
type NotificationBase = {
|
||||||
directory?: string
|
directory?: string
|
||||||
session?: string
|
session?: string
|
||||||
metadata?: any
|
metadata?: unknown
|
||||||
time: number
|
time: number
|
||||||
viewed: boolean
|
viewed: boolean
|
||||||
}
|
}
|
||||||
@@ -84,89 +84,93 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
|
|
||||||
const index = createMemo(() => buildNotificationIndex(store.list))
|
const index = createMemo(() => buildNotificationIndex(store.list))
|
||||||
|
|
||||||
const lookup = (directory: string, sessionID?: string) => {
|
const lookup = async (directory: string, sessionID?: string) => {
|
||||||
if (!sessionID) return Promise.resolve(undefined)
|
if (!sessionID) return undefined
|
||||||
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
const [syncStore] = globalSync.child(directory, { bootstrap: false })
|
||||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||||
if (match.found) return Promise.resolve(syncStore.session[match.index])
|
if (match.found) return syncStore.session[match.index]
|
||||||
return globalSDK.client.session
|
return globalSDK.client.session
|
||||||
.get({ directory, sessionID })
|
.get({ directory, sessionID })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||||
|
const activeDirectory = currentDirectory()
|
||||||
|
const activeSession = currentSession()
|
||||||
|
if (!activeDirectory) return false
|
||||||
|
if (!activeSession) return false
|
||||||
|
if (!sessionID) return false
|
||||||
|
if (directory !== activeDirectory) return false
|
||||||
|
return sessionID === activeSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (!session) return
|
||||||
|
if (session.parentID) return
|
||||||
|
|
||||||
|
playSound(soundSrc(settings.sounds.agent()))
|
||||||
|
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "turn-complete",
|
||||||
|
session: sessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
|
if (settings.notifications.agent()) {
|
||||||
|
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionError = (
|
||||||
|
directory: string,
|
||||||
|
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||||
|
time: number,
|
||||||
|
) => {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
void lookup(directory, sessionID).then((session) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (session?.parentID) return
|
||||||
|
|
||||||
|
playSound(soundSrc(settings.sounds.errors()))
|
||||||
|
|
||||||
|
const error = "error" in event.properties ? event.properties.error : undefined
|
||||||
|
append({
|
||||||
|
directory,
|
||||||
|
time,
|
||||||
|
viewed: viewedInCurrentSession(directory, sessionID),
|
||||||
|
type: "error",
|
||||||
|
session: sessionID ?? "global",
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
const description =
|
||||||
|
session?.title ??
|
||||||
|
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||||
|
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||||
|
if (settings.notifications.errors()) {
|
||||||
|
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||||
|
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const time = Date.now()
|
const time = Date.now()
|
||||||
const viewed = (sessionID?: string) => {
|
if (event.type === "session.idle") {
|
||||||
const activeDirectory = currentDirectory()
|
handleSessionIdle(directory, event, time)
|
||||||
const activeSession = currentSession()
|
return
|
||||||
if (!activeDirectory) return false
|
|
||||||
if (!activeSession) return false
|
|
||||||
if (!sessionID) return false
|
|
||||||
if (directory !== activeDirectory) return false
|
|
||||||
return sessionID === activeSession
|
|
||||||
}
|
|
||||||
switch (event.type) {
|
|
||||||
case "session.idle": {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (!session) return
|
|
||||||
if (session.parentID) return
|
|
||||||
|
|
||||||
playSound(soundSrc(settings.sounds.agent()))
|
|
||||||
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewed(sessionID),
|
|
||||||
type: "turn-complete",
|
|
||||||
session: sessionID,
|
|
||||||
})
|
|
||||||
|
|
||||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
|
||||||
if (settings.notifications.agent()) {
|
|
||||||
void platform.notify(
|
|
||||||
language.t("notification.session.responseReady.title"),
|
|
||||||
session.title ?? sessionID,
|
|
||||||
href,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "session.error": {
|
|
||||||
const sessionID = event.properties.sessionID
|
|
||||||
void lookup(directory, sessionID).then((session) => {
|
|
||||||
if (meta.disposed) return
|
|
||||||
if (session?.parentID) return
|
|
||||||
|
|
||||||
playSound(soundSrc(settings.sounds.errors()))
|
|
||||||
|
|
||||||
const error = "error" in event.properties ? event.properties.error : undefined
|
|
||||||
append({
|
|
||||||
directory,
|
|
||||||
time,
|
|
||||||
viewed: viewed(sessionID),
|
|
||||||
type: "error",
|
|
||||||
session: sessionID ?? "global",
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
const description =
|
|
||||||
session?.title ??
|
|
||||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
|
||||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
|
||||||
if (settings.notifications.errors()) {
|
|
||||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
handleSessionError(directory, event, time)
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
meta.disposed = true
|
meta.disposed = true
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAutoAcceptPermissionConfig(permission: unknown) {
|
function hasPermissionPromptRules(permission: unknown) {
|
||||||
if (!permission) return false
|
if (!permission) return false
|
||||||
if (typeof permission === "string") return permission !== "allow"
|
if (typeof permission === "string") return permission !== "allow"
|
||||||
if (typeof permission !== "object") return false
|
if (typeof permission !== "object") return false
|
||||||
@@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
const directory = decode64(params.dir)
|
const directory = decode64(params.dir)
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = globalSync.child(directory)
|
const [store] = globalSync.child(directory)
|
||||||
return hasAutoAcceptPermissionConfig(store.config.permission)
|
return hasPermissionPromptRules(store.config.permission)
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
@@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
const MAX_RESPONDED = 1000
|
const MAX_RESPONDED = 1000
|
||||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||||
const responded = new Map<string, number>()
|
const responded = new Map<string, number>()
|
||||||
|
const enableVersion = new Map<string, number>()
|
||||||
|
|
||||||
function pruneResponded(now: number) {
|
function pruneResponded(now: number) {
|
||||||
for (const [id, ts] of responded) {
|
for (const [id, ts] of responded) {
|
||||||
@@ -114,6 +115,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||||
|
const key = acceptKey(sessionID, directory)
|
||||||
|
const next = (enableVersion.get(key) ?? 0) + 1
|
||||||
|
enableVersion.set(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribe = globalSDK.event.listen((e) => {
|
const unsubscribe = globalSDK.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.asked") return
|
||||||
@@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
|
|
||||||
function enable(sessionID: string, directory: string) {
|
function enable(sessionID: string, directory: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
|
const version = bumpEnableVersion(sessionID, directory)
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.autoAcceptEdits[key] = true
|
draft.autoAcceptEdits[key] = true
|
||||||
@@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
globalSDK.client.permission
|
globalSDK.client.permission
|
||||||
.list({ directory })
|
.list({ directory })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
|
if (enableVersion.get(key) !== version) return
|
||||||
|
if (!isAutoAccepting(sessionID, directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const perm of x.data ?? []) {
|
||||||
if (!perm?.id) continue
|
if (!perm?.id) continue
|
||||||
if (perm.sessionID !== sessionID) continue
|
if (perm.sessionID !== sessionID) continue
|
||||||
@@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disable(sessionID: string, directory?: string) {
|
function disable(sessionID: string, directory?: string) {
|
||||||
|
bumpEnableVersion(sessionID, directory)
|
||||||
const key = directory ? acceptKey(sessionID, directory) : undefined
|
const key = directory ? acceptKey(sessionID, directory) : undefined
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
|
||||||
|
type PickerPaths = string | string[] | null
|
||||||
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
|
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
|
||||||
|
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
|
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||||
|
|
||||||
export type Platform = {
|
export type Platform = {
|
||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "desktop"
|
platform: "web" | "desktop"
|
||||||
@@ -31,19 +37,19 @@ export type Platform = {
|
|||||||
notify(title: string, description?: string, href?: string): Promise<void>
|
notify(title: string, description?: string, href?: string): Promise<void>
|
||||||
|
|
||||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||||
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||||
|
|
||||||
/** Open native file picker dialog (Tauri only) */
|
/** Open native file picker dialog (Tauri only) */
|
||||||
openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
|
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
||||||
|
|
||||||
/** Save file picker dialog (Tauri only) */
|
/** Save file picker dialog (Tauri only) */
|
||||||
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
|
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||||
|
|
||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||||
|
|
||||||
/** Check for updates (Tauri only) */
|
/** Check for updates (Tauri only) */
|
||||||
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
|
checkUpdate?(): Promise<UpdateInfo>
|
||||||
|
|
||||||
/** Install updates (Tauri only) */
|
/** Install updates (Tauri only) */
|
||||||
update?(): Promise<void>
|
update?(): Promise<void>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -60,27 +60,23 @@ function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||||
|
switch (partA.type) {
|
||||||
|
case "text":
|
||||||
|
return partB.type === "text" && partA.content === partB.content
|
||||||
|
case "file":
|
||||||
|
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
|
||||||
|
case "agent":
|
||||||
|
return partB.type === "agent" && partA.name === partB.name
|
||||||
|
case "image":
|
||||||
|
return partB.type === "image" && partA.id === partB.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||||
if (promptA.length !== promptB.length) return false
|
if (promptA.length !== promptB.length) return false
|
||||||
for (let i = 0; i < promptA.length; i++) {
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
const partA = promptA[i]
|
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||||
const partB = promptB[i]
|
|
||||||
if (partA.type !== partB.type) return false
|
|
||||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (partA.type === "file") {
|
|
||||||
const fileA = partA as FileAttachmentPart
|
|
||||||
const fileB = partB as FileAttachmentPart
|
|
||||||
if (fileA.path !== fileB.path) return false
|
|
||||||
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
|
|
||||||
}
|
|
||||||
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -104,6 +100,48 @@ function clonePrompt(prompt: Prompt): Prompt {
|
|||||||
return prompt.map(clonePart)
|
return prompt.map(clonePart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contextItemKey(item: ContextItem) {
|
||||||
|
if (item.type !== "file") return item.type
|
||||||
|
const start = item.selection?.startLine
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
|
||||||
|
if (item.commentID) {
|
||||||
|
return `${key}:c=${item.commentID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment) return key
|
||||||
|
const digest = checksum(comment) ?? comment
|
||||||
|
return `${key}:c=${digest.slice(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptActions(
|
||||||
|
setStore: SetStoreFunction<{
|
||||||
|
prompt: Prompt
|
||||||
|
cursor?: number
|
||||||
|
context: {
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
|
const next = clonePrompt(prompt)
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", next)
|
||||||
|
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||||
|
setStore("cursor", 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_PROMPT_SESSIONS = 20
|
const MAX_PROMPT_SESSIONS = 20
|
||||||
|
|
||||||
@@ -134,21 +172,7 @@ function createPromptSession(dir: string, id: string | undefined) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function keyForItem(item: ContextItem) {
|
const actions = createPromptActions(setStore)
|
||||||
if (item.type !== "file") return item.type
|
|
||||||
const start = item.selection?.startLine
|
|
||||||
const end = item.selection?.endLine
|
|
||||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
|
||||||
|
|
||||||
if (item.commentID) {
|
|
||||||
return `${key}:c=${item.commentID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const comment = item.comment?.trim()
|
|
||||||
if (!comment) return key
|
|
||||||
const digest = checksum(comment) ?? comment
|
|
||||||
return `${key}:c=${digest.slice(0, 8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
@@ -158,7 +182,7 @@ function createPromptSession(dir: string, id: string | undefined) {
|
|||||||
context: {
|
context: {
|
||||||
items: createMemo(() => store.context.items),
|
items: createMemo(() => store.context.items),
|
||||||
add(item: ContextItem) {
|
add(item: ContextItem) {
|
||||||
const key = keyForItem(item)
|
const key = contextItemKey(item)
|
||||||
if (store.context.items.find((x) => x.key === key)) return
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
},
|
},
|
||||||
@@ -166,19 +190,8 @@ function createPromptSession(dir: string, id: string | undefined) {
|
|||||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
set: actions.set,
|
||||||
const next = clonePrompt(prompt)
|
reset: actions.reset,
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", next)
|
|
||||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
||||||
setStore("cursor", 0)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
|||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
|
type SDKEventMap = {
|
||||||
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { directory: Accessor<string> }) => {
|
init: (props: { directory: Accessor<string> }) => {
|
||||||
@@ -21,9 +25,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const unsub = globalSDK.event.on(directory(), (event) => {
|
const unsub = globalSDK.event.on(directory(), (event) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { checkServerHealth } from "@/utils/server-health"
|
import { checkServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
|
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
export function normalizeServerUrl(input: string) {
|
export function normalizeServerUrl(input: string) {
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
@@ -48,24 +49,38 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const healthy = () => state.healthy
|
const healthy = () => state.healthy
|
||||||
|
|
||||||
function setActive(input: string) {
|
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
|
||||||
const url = normalizeServerUrl(input)
|
|
||||||
if (!url) return
|
function reconcileStartup() {
|
||||||
setState("active", url)
|
const fallback = defaultUrl()
|
||||||
|
if (!fallback) return
|
||||||
|
|
||||||
|
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
|
||||||
|
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
|
||||||
|
if (!props.isSidecar) {
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", list)
|
||||||
|
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
|
||||||
|
setState("active", fallback)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextList = list.includes(fallback) ? list : [...list, fallback]
|
||||||
|
batch(() => {
|
||||||
|
setStore("list", nextList)
|
||||||
|
setStore("currentSidecarUrl", fallback)
|
||||||
|
setState("active", fallback)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function add(input: string) {
|
function updateServerList(url: string, remove = false) {
|
||||||
const url = normalizeServerUrl(input)
|
if (remove) {
|
||||||
if (!url) return
|
const list = store.list.filter((x) => x !== url)
|
||||||
|
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
|
||||||
const fallback = normalizeServerUrl(props.defaultUrl)
|
|
||||||
if (fallback && url === fallback) {
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!store.list.includes(url)) {
|
setStore("list", list)
|
||||||
// Add the fallback url to the list if it's not already in the list
|
setState("active", next)
|
||||||
setStore("list", store.list.length, url)
|
|
||||||
}
|
|
||||||
setState("active", url)
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -78,51 +93,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(input: string) {
|
function startHealthPolling(url: string) {
|
||||||
const url = normalizeServerUrl(input)
|
|
||||||
if (!url) return
|
|
||||||
|
|
||||||
const list = store.list.filter((x) => x !== url)
|
|
||||||
const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
setStore("list", list)
|
|
||||||
setState("active", next)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
if (state.active) return
|
|
||||||
const url = normalizeServerUrl(props.defaultUrl)
|
|
||||||
if (!url) return
|
|
||||||
batch(() => {
|
|
||||||
// Remove the previous startup sidecar url
|
|
||||||
if (store.currentSidecarUrl) {
|
|
||||||
remove(store.currentSidecarUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the new sidecar url
|
|
||||||
if (props.isSidecar && props.defaultUrl) {
|
|
||||||
add(props.defaultUrl)
|
|
||||||
setStore("currentSidecarUrl", props.defaultUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
setState("active", url)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const url = state.active
|
|
||||||
if (!url) return
|
|
||||||
|
|
||||||
setState("healthy", undefined)
|
|
||||||
|
|
||||||
let alive = true
|
let alive = true
|
||||||
let busy = false
|
let busy = false
|
||||||
|
|
||||||
@@ -140,12 +111,48 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run()
|
||||||
const interval = setInterval(run, 10_000)
|
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
|
||||||
|
return () => {
|
||||||
onCleanup(() => {
|
|
||||||
alive = false
|
alive = false
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(input: string) {
|
||||||
|
const url = normalizeServerUrl(input)
|
||||||
|
if (!url) return
|
||||||
|
setState("active", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(input: string) {
|
||||||
|
const url = normalizeServerUrl(input)
|
||||||
|
if (!url) return
|
||||||
|
updateServerList(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(input: string) {
|
||||||
|
const url = normalizeServerUrl(input)
|
||||||
|
if (!url) return
|
||||||
|
updateServerList(url, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready()) return
|
||||||
|
if (state.active) return
|
||||||
|
reconcileStartup()
|
||||||
|
})
|
||||||
|
|
||||||
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
|
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const url = state.active
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
setState("healthy", undefined)
|
||||||
|
onCleanup(startHealthPolling(url))
|
||||||
})
|
})
|
||||||
|
|
||||||
const origin = createMemo(() => projectsKey(state.active))
|
const origin = createMemo(() => projectsKey(state.active))
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ export function monoFontFamily(font: string | undefined) {
|
|||||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||||
|
return createMemo(() => read() ?? fallback)
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -101,27 +105,27 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
return store
|
return store
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
|
autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
|
||||||
setAutoSave(value: boolean) {
|
setAutoSave(value: boolean) {
|
||||||
setStore("general", "autoSave", value)
|
setStore("general", "autoSave", value)
|
||||||
},
|
},
|
||||||
releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
|
releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
|
||||||
setReleaseNotes(value: boolean) {
|
setReleaseNotes(value: boolean) {
|
||||||
setStore("general", "releaseNotes", value)
|
setStore("general", "releaseNotes", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup),
|
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||||
setStartup(value: boolean) {
|
setStartup(value: boolean) {
|
||||||
setStore("updates", "startup", value)
|
setStore("updates", "startup", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||||
setFontSize(value: number) {
|
setFontSize(value: number) {
|
||||||
setStore("appearance", "fontSize", value)
|
setStore("appearance", "fontSize", value)
|
||||||
},
|
},
|
||||||
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
|
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
||||||
setFont(value: string) {
|
setFont(value: string) {
|
||||||
setStore("appearance", "font", value)
|
setStore("appearance", "font", value)
|
||||||
},
|
},
|
||||||
@@ -132,42 +136,47 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("keybinds", action, keybind)
|
setStore("keybinds", action, keybind)
|
||||||
},
|
},
|
||||||
reset(action: string) {
|
reset(action: string) {
|
||||||
setStore("keybinds", action, undefined!)
|
setStore("keybinds", (current) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(current, action)) return current
|
||||||
|
const next = { ...current }
|
||||||
|
delete next[action]
|
||||||
|
return next
|
||||||
|
})
|
||||||
},
|
},
|
||||||
resetAll() {
|
resetAll() {
|
||||||
setStore("keybinds", reconcile({}))
|
setStore("keybinds", reconcile({}))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
|
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
|
||||||
setAutoApprove(value: boolean) {
|
setAutoApprove(value: boolean) {
|
||||||
setStore("permissions", "autoApprove", value)
|
setStore("permissions", "autoApprove", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
|
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||||
setAgent(value: boolean) {
|
setAgent(value: boolean) {
|
||||||
setStore("notifications", "agent", value)
|
setStore("notifications", "agent", value)
|
||||||
},
|
},
|
||||||
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
|
permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
|
||||||
setPermissions(value: boolean) {
|
setPermissions(value: boolean) {
|
||||||
setStore("notifications", "permissions", value)
|
setStore("notifications", "permissions", value)
|
||||||
},
|
},
|
||||||
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
|
errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
|
||||||
setErrors(value: boolean) {
|
setErrors(value: boolean) {
|
||||||
setStore("notifications", "errors", value)
|
setStore("notifications", "errors", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sounds: {
|
sounds: {
|
||||||
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
|
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
|
||||||
setAgent(value: string) {
|
setAgent(value: string) {
|
||||||
setStore("sounds", "agent", value)
|
setStore("sounds", "agent", value)
|
||||||
},
|
},
|
||||||
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
|
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
|
||||||
setPermissions(value: string) {
|
setPermissions(value: string) {
|
||||||
setStore("sounds", "permissions", value)
|
setStore("sounds", "permissions", value)
|
||||||
},
|
},
|
||||||
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
|
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
|
||||||
setErrors(value: string) {
|
setErrors(value: string) {
|
||||||
setStore("sounds", "errors", value)
|
setStore("sounds", "errors", value)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import { useGlobalSync } from "./global-sync"
|
|||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
function sortParts(parts: Part[]) {
|
||||||
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||||
|
const pending = map.get(key)
|
||||||
|
if (pending) return pending
|
||||||
|
const promise = task().finally(() => {
|
||||||
|
map.delete(key)
|
||||||
|
})
|
||||||
|
map.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
@@ -36,7 +50,7 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
|
|||||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||||
messages.splice(result.index, 0, input.message)
|
messages.splice(result.index, 0, input.message)
|
||||||
}
|
}
|
||||||
draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
draft.part[input.message.id] = sortParts(input.parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||||
@@ -48,6 +62,34 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
|
|||||||
delete draft.part[input.messageID]
|
delete draft.part[input.messageID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
|
||||||
|
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||||
|
if (!messages) return [input.message]
|
||||||
|
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||||
|
const next = [...messages]
|
||||||
|
next.splice(result.index, 0, input.message)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setStore("part", input.message.id, sortParts(input.parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
|
||||||
|
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
|
||||||
|
if (!messages) return messages
|
||||||
|
const result = Binary.search(messages, input.messageID, (m) => m.id)
|
||||||
|
if (!result.found) return messages
|
||||||
|
const next = [...messages]
|
||||||
|
next.splice(result.index, 1)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setStore("part", (part: Record<string, Part[] | undefined>) => {
|
||||||
|
if (!(input.messageID in part)) return part
|
||||||
|
const next = { ...part }
|
||||||
|
delete next[input.messageID]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -63,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return globalSync.child(directory)
|
return globalSync.child(directory)
|
||||||
}
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const chunk = 400
|
const messagePageSize = 400
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
@@ -81,8 +123,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limitFor = (count: number) => {
|
const limitFor = (count: number) => {
|
||||||
if (count <= chunk) return chunk
|
if (count <= messagePageSize) return messagePageSize
|
||||||
return Math.ceil(count / chunk) * chunk
|
return Math.ceil(count / messagePageSize) * messagePageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
|
const messages = await retry(() =>
|
||||||
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||||
|
)
|
||||||
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
|
const session = items
|
||||||
|
.map((x) => x.info)
|
||||||
|
.filter((m) => !!m?.id)
|
||||||
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
part,
|
||||||
|
complete: session.length < input.limit,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMessages = async (input: {
|
const loadMessages = async (input: {
|
||||||
@@ -96,30 +155,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
|
|
||||||
setMeta("loading", key, true)
|
setMeta("loading", key, true)
|
||||||
await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
|
await fetchMessages(input)
|
||||||
.then((messages) => {
|
.then((next) => {
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
|
||||||
const next = items
|
|
||||||
.map((x) => x.info)
|
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
|
for (const message of next.part) {
|
||||||
for (const message of items) {
|
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
|
||||||
input.setStore(
|
|
||||||
"part",
|
|
||||||
message.info.id,
|
|
||||||
reconcile(
|
|
||||||
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setMeta("limit", key, input.limit)
|
setMeta("limit", key, input.limit)
|
||||||
setMeta("complete", key, next.length < input.limit)
|
setMeta("complete", key, next.complete)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -151,19 +195,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
optimistic: {
|
optimistic: {
|
||||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||||
const [, setStore] = target(input.directory)
|
const [, setStore] = target(input.directory)
|
||||||
setStore(
|
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
|
||||||
produce((draft) => {
|
|
||||||
applyOptimisticAdd(draft as OptimisticStore, input)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||||
const [, setStore] = target(input.directory)
|
const [, setStore] = target(input.directory)
|
||||||
setStore(
|
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
|
||||||
produce((draft) => {
|
|
||||||
applyOptimisticRemove(draft as OptimisticStore, input)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
addOptimisticMessage(input: {
|
addOptimisticMessage(input: {
|
||||||
@@ -182,15 +218,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
model: input.model,
|
model: input.model,
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setStore(
|
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||||
produce((draft) => {
|
sessionID: input.sessionID,
|
||||||
applyOptimisticAdd(draft as OptimisticStore, {
|
message,
|
||||||
sessionID: input.sessionID,
|
parts: input.parts,
|
||||||
message,
|
})
|
||||||
parts: input.parts,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
async sync(sessionID: string) {
|
async sync(sessionID: string) {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
@@ -205,11 +237,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const hasMessages = store.message[sessionID] !== undefined
|
const hasMessages = store.message[sessionID] !== undefined
|
||||||
const hydrated = meta.limit[key] !== undefined
|
const hydrated = meta.limit[key] !== undefined
|
||||||
if (hasSession && hasMessages && hydrated) return
|
if (hasSession && hasMessages && hydrated) return
|
||||||
const pending = inflight.get(key)
|
|
||||||
if (pending) return pending
|
|
||||||
|
|
||||||
const count = store.message[sessionID]?.length ?? 0
|
const count = store.message[sessionID]?.length ?? 0
|
||||||
const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count)
|
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
|
||||||
|
|
||||||
const sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
@@ -240,14 +270,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
limit,
|
limit,
|
||||||
})
|
})
|
||||||
|
|
||||||
const promise = Promise.all([sessionReq, messagesReq])
|
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
||||||
.then(() => {})
|
|
||||||
.finally(() => {
|
|
||||||
inflight.delete(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
inflight.set(key, promise)
|
|
||||||
return promise
|
|
||||||
},
|
},
|
||||||
async diff(sessionID: string) {
|
async diff(sessionID: string) {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
@@ -256,19 +279,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (store.session_diff[sessionID] !== undefined) return
|
if (store.session_diff[sessionID] !== undefined) return
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const pending = inflightDiff.get(key)
|
return runInflight(inflightDiff, key, () =>
|
||||||
if (pending) return pending
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
|
|
||||||
const promise = retry(() => client.session.diff({ sessionID }))
|
|
||||||
.then((diff) => {
|
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||||
})
|
}),
|
||||||
.finally(() => {
|
)
|
||||||
inflightDiff.delete(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
inflightDiff.set(key, promise)
|
|
||||||
return promise
|
|
||||||
},
|
},
|
||||||
async todo(sessionID: string) {
|
async todo(sessionID: string) {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
@@ -277,19 +292,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (store.todo[sessionID] !== undefined) return
|
if (store.todo[sessionID] !== undefined) return
|
||||||
|
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const pending = inflightTodo.get(key)
|
return runInflight(inflightTodo, key, () =>
|
||||||
if (pending) return pending
|
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||||
|
|
||||||
const promise = retry(() => client.session.todo({ sessionID }))
|
|
||||||
.then((todo) => {
|
|
||||||
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
|
||||||
})
|
}),
|
||||||
.finally(() => {
|
)
|
||||||
inflightTodo.delete(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
inflightTodo.set(key, promise)
|
|
||||||
return promise
|
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
more(sessionID: string) {
|
more(sessionID: string) {
|
||||||
@@ -304,7 +311,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(sdk.directory, sessionID)
|
const key = keyFor(sdk.directory, sessionID)
|
||||||
return meta.loading[key] ?? false
|
return meta.loading[key] ?? false
|
||||||
},
|
},
|
||||||
async loadMore(sessionID: string, count = chunk) {
|
async loadMore(sessionID: string, count = messagePageSize) {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
@@ -312,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
if (meta.complete[key]) return
|
if (meta.complete[key]) return
|
||||||
|
|
||||||
const currentLimit = meta.limit[key] ?? chunk
|
const currentLimit = meta.limit[key] ?? messagePageSize
|
||||||
await loadMessages({
|
await loadMessages({
|
||||||
directory,
|
directory,
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -79,19 +79,38 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
|
const pickNextTerminalNumber = () => {
|
||||||
const id = event.properties.id
|
const existingTitleNumbers = new Set(
|
||||||
if (!store.all.some((x) => x.id === id)) return
|
store.all.flatMap((pty) => {
|
||||||
|
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||||
|
if (direct !== undefined) return [direct]
|
||||||
|
const parsed = numberFromTitle(pty.title)
|
||||||
|
if (parsed === undefined) return []
|
||||||
|
return [parsed]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
|
||||||
|
(number) => !existingTitleNumbers.has(number),
|
||||||
|
) ?? 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeExited = (id: string) => {
|
||||||
|
const all = store.all
|
||||||
|
const index = all.findIndex((x) => x.id === id)
|
||||||
|
if (index === -1) return
|
||||||
|
const filtered = all.filter((x) => x.id !== id)
|
||||||
|
const active = store.active === id ? filtered[0]?.id : store.active
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore(
|
setStore("all", filtered)
|
||||||
"all",
|
setStore("active", active)
|
||||||
store.all.filter((x) => x.id !== id),
|
|
||||||
)
|
|
||||||
if (store.active === id) {
|
|
||||||
const remaining = store.all.filter((x) => x.id !== id)
|
|
||||||
setStore("active", remaining[0]?.id)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
|
||||||
|
removeExited(event.properties.id)
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
@@ -117,7 +136,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
all: createMemo(() => Object.values(store.all)),
|
all: createMemo(() => store.all),
|
||||||
active: createMemo(() => store.active),
|
active: createMemo(() => store.active),
|
||||||
clear() {
|
clear() {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
@@ -126,20 +145,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
new() {
|
new() {
|
||||||
const existingTitleNumbers = new Set(
|
const nextNumber = pickNextTerminalNumber()
|
||||||
store.all.flatMap((pty) => {
|
|
||||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
|
||||||
if (direct !== undefined) return [direct]
|
|
||||||
const parsed = numberFromTitle(pty.title)
|
|
||||||
if (parsed === undefined) return []
|
|
||||||
return [parsed]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const nextNumber =
|
|
||||||
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
|
|
||||||
(number) => !existingTitleNumbers.has(number),
|
|
||||||
) ?? 1
|
|
||||||
|
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.create({ title: `Terminal ${nextNumber}` })
|
.create({ title: `Terminal ${nextNumber}` })
|
||||||
@@ -162,10 +168,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
const previous = store.all.find((x) => x.id === pty.id)
|
||||||
if (index !== -1) {
|
if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item)))
|
||||||
setStore("all", index, (existing) => ({ ...existing, ...pty }))
|
|
||||||
}
|
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: pty.id,
|
ptyID: pty.id,
|
||||||
@@ -173,6 +177,9 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
if (previous) {
|
||||||
|
setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item)))
|
||||||
|
}
|
||||||
console.error("Failed to update terminal", error)
|
console.error("Failed to update terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,97 +8,117 @@ import pkg from "../package.json"
|
|||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const getLocale = () => {
|
||||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
if (typeof navigator !== "object") return "en" as const
|
||||||
const locale = (() => {
|
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||||
if (typeof navigator !== "object") return "en" as const
|
for (const language of languages) {
|
||||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
if (!language) continue
|
||||||
for (const language of languages) {
|
if (language.toLowerCase().startsWith("zh")) return "zh" as const
|
||||||
if (!language) continue
|
}
|
||||||
if (language.toLowerCase().startsWith("zh")) return "zh" as const
|
return "en" as const
|
||||||
}
|
}
|
||||||
return "en" as const
|
|
||||||
})()
|
|
||||||
|
|
||||||
|
const getRootNotFoundError = () => {
|
||||||
const key = "error.dev.rootNotFound" as const
|
const key = "error.dev.rootNotFound" as const
|
||||||
const message = locale === "zh" ? (zh[key] ?? en[key]) : en[key]
|
const locale = getLocale()
|
||||||
throw new Error(message)
|
return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStorage = (key: string) => {
|
||||||
|
if (typeof localStorage === "undefined") return null
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setStorage = (key: string, value: string | null) => {
|
||||||
|
if (typeof localStorage === "undefined") return
|
||||||
|
try {
|
||||||
|
if (value !== null) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
|
||||||
|
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
|
||||||
|
|
||||||
|
const notify: Platform["notify"] = async (title, description, href) => {
|
||||||
|
if (!("Notification" in window)) return
|
||||||
|
|
||||||
|
const permission =
|
||||||
|
Notification.permission === "default"
|
||||||
|
? await Notification.requestPermission().catch(() => "denied")
|
||||||
|
: Notification.permission
|
||||||
|
|
||||||
|
if (permission !== "granted") return
|
||||||
|
|
||||||
|
const inView = document.visibilityState === "visible" && document.hasFocus()
|
||||||
|
if (inView) return
|
||||||
|
|
||||||
|
const notification = new Notification(title, {
|
||||||
|
body: description ?? "",
|
||||||
|
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||||
|
})
|
||||||
|
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus()
|
||||||
|
if (href) {
|
||||||
|
window.history.pushState(null, "", href)
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||||
|
}
|
||||||
|
notification.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLink: Platform["openLink"] = (url) => {
|
||||||
|
window.open(url, "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
const back: Platform["back"] = () => {
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const forward: Platform["forward"] = () => {
|
||||||
|
window.history.forward()
|
||||||
|
}
|
||||||
|
|
||||||
|
const restart: Platform["restart"] = async () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.getElementById("root")
|
||||||
|
if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||||
|
throw new Error(getRootNotFoundError())
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform: Platform = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
openLink(url: string) {
|
openLink,
|
||||||
window.open(url, "_blank")
|
back,
|
||||||
},
|
forward,
|
||||||
back() {
|
restart,
|
||||||
window.history.back()
|
notify,
|
||||||
},
|
getDefaultServerUrl: readDefaultServerUrl,
|
||||||
forward() {
|
setDefaultServerUrl: writeDefaultServerUrl,
|
||||||
window.history.forward()
|
|
||||||
},
|
|
||||||
restart: async () => {
|
|
||||||
window.location.reload()
|
|
||||||
},
|
|
||||||
notify: async (title, description, href) => {
|
|
||||||
if (!("Notification" in window)) return
|
|
||||||
|
|
||||||
const permission =
|
|
||||||
Notification.permission === "default"
|
|
||||||
? await Notification.requestPermission().catch(() => "denied")
|
|
||||||
: Notification.permission
|
|
||||||
|
|
||||||
if (permission !== "granted") return
|
|
||||||
|
|
||||||
const inView = document.visibilityState === "visible" && document.hasFocus()
|
|
||||||
if (inView) return
|
|
||||||
|
|
||||||
await Promise.resolve()
|
|
||||||
.then(() => {
|
|
||||||
const notification = new Notification(title, {
|
|
||||||
body: description ?? "",
|
|
||||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
|
||||||
})
|
|
||||||
notification.onclick = () => {
|
|
||||||
window.focus()
|
|
||||||
if (href) {
|
|
||||||
window.history.pushState(null, "", href)
|
|
||||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
|
||||||
}
|
|
||||||
notification.close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
},
|
|
||||||
getDefaultServerUrl: () => {
|
|
||||||
if (typeof localStorage === "undefined") return null
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(DEFAULT_SERVER_URL_KEY)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setDefaultServerUrl: (url) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
if (url) {
|
|
||||||
localStorage.setItem(DEFAULT_SERVER_URL_KEY, url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
localStorage.removeItem(DEFAULT_SERVER_URL_KEY)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
if (root instanceof HTMLElement) {
|
||||||
() => (
|
render(
|
||||||
<PlatformProvider value={platform}>
|
() => (
|
||||||
<AppBaseProviders>
|
<PlatformProvider value={platform}>
|
||||||
<AppInterface />
|
<AppBaseProviders>
|
||||||
</AppBaseProviders>
|
<AppInterface />
|
||||||
</PlatformProvider>
|
</AppBaseProviders>
|
||||||
),
|
</PlatformProvider>
|
||||||
root!,
|
),
|
||||||
)
|
root,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
10
packages/app/src/env.d.ts
vendored
10
packages/app/src/env.d.ts
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
import "solid-js"
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||||
@@ -6,3 +8,11 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "solid-js" {
|
||||||
|
namespace JSX {
|
||||||
|
interface Directives {
|
||||||
|
sortable: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,47 @@
|
|||||||
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
|
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||||
|
const params = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sync = useSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataProvider
|
||||||
|
data={sync.data}
|
||||||
|
directory={props.directory}
|
||||||
|
onPermissionRespond={(input: {
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
response: "once" | "always" | "reject"
|
||||||
|
}) => sdk.client.permission.respond(input)}
|
||||||
|
onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
|
||||||
|
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
|
||||||
|
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||||
|
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||||
|
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
|
||||||
|
>
|
||||||
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
|
</DataProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
let invalid = ""
|
const [store, setStore] = createStore({ invalid: "" })
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
return decode64(params.dir) ?? ""
|
return decode64(params.dir) ?? ""
|
||||||
})
|
})
|
||||||
@@ -23,8 +49,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.dir) return
|
if (!params.dir) return
|
||||||
if (directory()) return
|
if (directory()) return
|
||||||
if (invalid === params.dir) return
|
if (store.invalid === params.dir) return
|
||||||
invalid = params.dir
|
setStore("invalid", params.dir)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
@@ -36,46 +62,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
<Show when={directory()}>
|
<Show when={directory()}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
{iife(() => {
|
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
|
||||||
const sync = useSync()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const respond = (input: {
|
|
||||||
sessionID: string
|
|
||||||
permissionID: string
|
|
||||||
response: "once" | "always" | "reject"
|
|
||||||
}) => sdk.client.permission.respond(input)
|
|
||||||
|
|
||||||
const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
|
|
||||||
sdk.client.question.reply(input)
|
|
||||||
|
|
||||||
const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
|
|
||||||
|
|
||||||
const navigateToSession = (sessionID: string) => {
|
|
||||||
navigate(`/${params.dir}/session/${sessionID}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionHref = (sessionID: string) => {
|
|
||||||
if (params.dir) return `/${params.dir}/session/${sessionID}`
|
|
||||||
return `/session/${sessionID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncSession = (sessionID: string) => sync.session.sync(sessionID)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataProvider
|
|
||||||
data={sync.data}
|
|
||||||
directory={directory()}
|
|
||||||
onPermissionRespond={respond}
|
|
||||||
onQuestionReply={replyToQuestion}
|
|
||||||
onQuestionReject={rejectQuestion}
|
|
||||||
onNavigateToSession={navigateToSession}
|
|
||||||
onSessionHref={sessionHref}
|
|
||||||
onSyncSession={syncSession}
|
|
||||||
>
|
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
|
||||||
</DataProvider>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SyncProvider>
|
</SyncProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ export type InitError = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||||
|
const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n"
|
||||||
|
|
||||||
|
function isIssue(value: unknown): value is { message: string; path: string[] } {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (!("message" in value) || !("path" in value)) return false
|
||||||
|
const message = (value as { message: unknown }).message
|
||||||
|
const path = (value as { path: unknown }).path
|
||||||
|
if (typeof message !== "string") return false
|
||||||
|
if (!Array.isArray(path)) return false
|
||||||
|
return path.every((part) => typeof part === "string")
|
||||||
|
}
|
||||||
|
|
||||||
function isInitError(error: unknown): error is InitError {
|
function isInitError(error: unknown): error is InitError {
|
||||||
return (
|
return (
|
||||||
@@ -112,9 +123,7 @@ function formatInitError(error: InitError, t: Translator): string {
|
|||||||
}
|
}
|
||||||
case "ConfigInvalidError": {
|
case "ConfigInvalidError": {
|
||||||
const issues = Array.isArray(data.issues)
|
const issues = Array.isArray(data.issues)
|
||||||
? data.issues.map(
|
? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join("."))
|
||||||
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
|
|
||||||
)
|
|
||||||
: []
|
: []
|
||||||
const message = typeof data.message === "string" ? data.message : ""
|
const message = typeof data.message === "string" ? data.message : ""
|
||||||
const path = typeof data.path === "string" ? data.path : safeJson(data.path)
|
const path = typeof data.path === "string" ? data.path : safeJson(data.path)
|
||||||
@@ -139,14 +148,14 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
|
|||||||
if (isInitError(error)) {
|
if (isInitError(error)) {
|
||||||
const message = formatInitError(error, t)
|
const message = formatInitError(error, t)
|
||||||
if (depth > 0 && parentMessage === message) return ""
|
if (depth > 0 && parentMessage === message) return ""
|
||||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
|
const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
|
||||||
return indent + `${error.name}\n${message}`
|
return indent + `${error.name}\n${message}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const isDuplicate = depth > 0 && parentMessage === error.message
|
const isDuplicate = depth > 0 && parentMessage === error.message
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
|
const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
|
||||||
|
|
||||||
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
|
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
|
||||||
const stack = error.stack?.trim()
|
const stack = error.stack?.trim()
|
||||||
@@ -190,11 +199,11 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
|
|||||||
|
|
||||||
if (typeof error === "string") {
|
if (typeof error === "string") {
|
||||||
if (depth > 0 && parentMessage === error) return ""
|
if (depth > 0 && parentMessage === error) return ""
|
||||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
|
const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
|
||||||
return indent + error
|
return indent + error
|
||||||
}
|
}
|
||||||
|
|
||||||
const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
|
const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
|
||||||
return indent + safeJson(error)
|
return indent + safeJson(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,20 +221,35 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
checking: false,
|
checking: false,
|
||||||
version: undefined as string | undefined,
|
version: undefined as string | undefined,
|
||||||
|
actionError: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
setStore("checking", true)
|
setStore("checking", true)
|
||||||
const result = await platform.checkUpdate()
|
await platform
|
||||||
setStore("checking", false)
|
.checkUpdate()
|
||||||
if (result.updateAvailable && result.version) setStore("version", result.version)
|
.then((result) => {
|
||||||
|
setStore("actionError", undefined)
|
||||||
|
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setStore("actionError", formatError(err, language.t))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("checking", false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installUpdate() {
|
async function installUpdate() {
|
||||||
if (!platform.update || !platform.restart) return
|
if (!platform.update || !platform.restart) return
|
||||||
await platform.update()
|
await platform
|
||||||
await platform.restart()
|
.update()
|
||||||
|
.then(() => platform.restart!())
|
||||||
|
.then(() => setStore("actionError", undefined))
|
||||||
|
.catch((err) => {
|
||||||
|
setStore("actionError", formatError(err, language.t))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -266,6 +290,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={store.actionError}>
|
||||||
|
{(message) => <p class="text-xs text-text-danger-base text-center max-w-2xl">{message()}</p>}
|
||||||
|
</Show>
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<div class="flex items-center justify-center gap-1">
|
<div class="flex items-center justify-center gap-1">
|
||||||
{language.t("error.page.report.prefix")}
|
{language.t("error.page.report.prefix")}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export default function Home() {
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const serverDotClass = createMemo(() => {
|
||||||
|
const healthy = server.healthy()
|
||||||
|
if (healthy === true) return "bg-icon-success-base"
|
||||||
|
if (healthy === false) return "bg-icon-critical-base"
|
||||||
|
return "bg-border-weak-base"
|
||||||
|
})
|
||||||
|
|
||||||
function openProject(directory: string) {
|
function openProject(directory: string) {
|
||||||
layout.projects.open(directory)
|
layout.projects.open(directory)
|
||||||
server.projects.touch(directory)
|
server.projects.touch(directory)
|
||||||
@@ -73,9 +80,7 @@ export default function Home() {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"size-2 rounded-full": true,
|
"size-2 rounded-full": true,
|
||||||
"bg-icon-success-base": server.healthy() === true,
|
[serverDotClass()]: true,
|
||||||
"bg-icon-critical-base": server.healthy() === false,
|
|
||||||
"bg-border-weak-base": server.healthy() === undefined,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{server.name}
|
{server.name}
|
||||||
@@ -115,8 +120,7 @@ export default function Home() {
|
|||||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<Button class="px-3 mt-1" onClick={chooseProject}>
|
||||||
<Button class="px-3" onClick={chooseProject}>
|
|
||||||
{language.t("command.project.open")}
|
{language.t("command.project.open")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -207,6 +207,18 @@ export default function Layout(props: ParentProps) {
|
|||||||
const setEditor = editor.setEditor
|
const setEditor = editor.setEditor
|
||||||
const InlineEditor = editor.InlineEditor
|
const InlineEditor = editor.InlineEditor
|
||||||
|
|
||||||
|
const clearSidebarHoverState = () => {
|
||||||
|
if (layout.sidebar.opened()) return
|
||||||
|
setState("hoverSession", undefined)
|
||||||
|
setState("hoverProject", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateWithSidebarReset = (href: string) => {
|
||||||
|
clearSidebarHoverState()
|
||||||
|
navigate(href)
|
||||||
|
layout.mobileSidebar.hide()
|
||||||
|
}
|
||||||
|
|
||||||
function cycleTheme(direction = 1) {
|
function cycleTheme(direction = 1) {
|
||||||
const ids = availableThemeEntries().map(([id]) => id)
|
const ids = availableThemeEntries().map(([id]) => id)
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
@@ -252,166 +264,167 @@ export default function Layout(props: ParentProps) {
|
|||||||
setLocale(next)
|
setLocale(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
const useUpdatePolling = () =>
|
||||||
if (!platform.checkUpdate || !platform.update || !platform.restart) return
|
onMount(() => {
|
||||||
|
if (!platform.checkUpdate || !platform.update || !platform.restart) return
|
||||||
|
|
||||||
let toastId: number | undefined
|
let toastId: number | undefined
|
||||||
let interval: ReturnType<typeof setInterval> | undefined
|
let interval: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
async function pollUpdate() {
|
const pollUpdate = () =>
|
||||||
const { updateAvailable, version } = await platform.checkUpdate!()
|
platform.checkUpdate!().then(({ updateAvailable, version }) => {
|
||||||
if (updateAvailable && toastId === undefined) {
|
if (!updateAvailable) return
|
||||||
toastId = showToast({
|
if (toastId !== undefined) return
|
||||||
persistent: true,
|
toastId = showToast({
|
||||||
icon: "download",
|
persistent: true,
|
||||||
title: language.t("toast.update.title"),
|
icon: "download",
|
||||||
description: language.t("toast.update.description", { version: version ?? "" }),
|
title: language.t("toast.update.title"),
|
||||||
actions: [
|
description: language.t("toast.update.description", { version: version ?? "" }),
|
||||||
{
|
actions: [
|
||||||
label: language.t("toast.update.action.installRestart"),
|
{
|
||||||
onClick: async () => {
|
label: language.t("toast.update.action.installRestart"),
|
||||||
await platform.update!()
|
onClick: async () => {
|
||||||
await platform.restart!()
|
await platform.update!()
|
||||||
|
await platform.restart!()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: language.t("toast.update.action.notYet"),
|
||||||
label: language.t("toast.update.action.notYet"),
|
onClick: "dismiss",
|
||||||
onClick: "dismiss",
|
},
|
||||||
},
|
],
|
||||||
],
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!settings.ready()) return
|
if (!settings.ready()) return
|
||||||
|
|
||||||
if (!settings.updates.startup()) {
|
if (!settings.updates.startup()) {
|
||||||
|
if (interval === undefined) return
|
||||||
|
clearInterval(interval)
|
||||||
|
interval = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interval !== undefined) return
|
||||||
|
void pollUpdate()
|
||||||
|
interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
if (interval === undefined) return
|
if (interval === undefined) return
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
interval = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interval !== undefined) return
|
|
||||||
void pollUpdate()
|
|
||||||
interval = setInterval(pollUpdate, 10 * 60 * 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (interval === undefined) return
|
|
||||||
clearInterval(interval)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const toastBySession = new Map<string, number>()
|
|
||||||
const alertedAtBySession = new Map<string, number>()
|
|
||||||
const cooldownMs = 5000
|
|
||||||
|
|
||||||
const unsub = globalSDK.event.listen((e) => {
|
|
||||||
if (e.details?.type === "worktree.ready") {
|
|
||||||
setBusy(e.name, false)
|
|
||||||
WorktreeState.ready(e.name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.details?.type === "worktree.failed") {
|
|
||||||
setBusy(e.name, false)
|
|
||||||
WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
|
||||||
const title =
|
|
||||||
e.details.type === "permission.asked"
|
|
||||||
? language.t("notification.permission.title")
|
|
||||||
: language.t("notification.question.title")
|
|
||||||
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
|
|
||||||
const directory = e.name
|
|
||||||
const props = e.details.properties
|
|
||||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
|
||||||
|
|
||||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
|
||||||
const session = store.session.find((s) => s.id === props.sessionID)
|
|
||||||
const sessionKey = `${directory}:${props.sessionID}`
|
|
||||||
|
|
||||||
const sessionTitle = session?.title ?? language.t("command.session.new")
|
|
||||||
const projectName = getFilename(directory)
|
|
||||||
const description =
|
|
||||||
e.details.type === "permission.asked"
|
|
||||||
? language.t("notification.permission.description", { sessionTitle, projectName })
|
|
||||||
: language.t("notification.question.description", { sessionTitle, projectName })
|
|
||||||
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
|
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
|
|
||||||
if (now - lastAlerted < cooldownMs) return
|
|
||||||
alertedAtBySession.set(sessionKey, now)
|
|
||||||
|
|
||||||
if (e.details.type === "permission.asked") {
|
|
||||||
playSound(soundSrc(settings.sounds.permissions()))
|
|
||||||
if (settings.notifications.permissions()) {
|
|
||||||
void platform.notify(title, description, href)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.details.type === "question.asked") {
|
|
||||||
if (settings.notifications.agent()) {
|
|
||||||
void platform.notify(title, description, href)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSession = params.id
|
|
||||||
if (directory === currentDir() && props.sessionID === currentSession) return
|
|
||||||
if (directory === currentDir() && session?.parentID === currentSession) return
|
|
||||||
|
|
||||||
const existingToastId = toastBySession.get(sessionKey)
|
|
||||||
if (existingToastId !== undefined) toaster.dismiss(existingToastId)
|
|
||||||
|
|
||||||
const toastId = showToast({
|
|
||||||
persistent: true,
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: language.t("notification.action.goToSession"),
|
|
||||||
onClick: () => navigate(href),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: language.t("common.dismiss"),
|
|
||||||
onClick: "dismiss",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
toastBySession.set(sessionKey, toastId)
|
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
|
||||||
|
|
||||||
createEffect(() => {
|
const useSDKNotificationToasts = () =>
|
||||||
const currentSession = params.id
|
onMount(() => {
|
||||||
if (!currentDir() || !currentSession) return
|
const toastBySession = new Map<string, number>()
|
||||||
const sessionKey = `${currentDir()}:${currentSession}`
|
const alertedAtBySession = new Map<string, number>()
|
||||||
const toastId = toastBySession.get(sessionKey)
|
const cooldownMs = 5000
|
||||||
if (toastId !== undefined) {
|
|
||||||
|
const dismissSessionAlert = (sessionKey: string) => {
|
||||||
|
const toastId = toastBySession.get(sessionKey)
|
||||||
|
if (toastId === undefined) return
|
||||||
toaster.dismiss(toastId)
|
toaster.dismiss(toastId)
|
||||||
toastBySession.delete(sessionKey)
|
toastBySession.delete(sessionKey)
|
||||||
alertedAtBySession.delete(sessionKey)
|
alertedAtBySession.delete(sessionKey)
|
||||||
}
|
}
|
||||||
const [store] = globalSync.child(currentDir(), { bootstrap: false })
|
|
||||||
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
for (const child of childSessions) {
|
if (e.details?.type === "worktree.ready") {
|
||||||
const childKey = `${currentDir()}:${child.id}`
|
setBusy(e.name, false)
|
||||||
const childToastId = toastBySession.get(childKey)
|
WorktreeState.ready(e.name)
|
||||||
if (childToastId !== undefined) {
|
return
|
||||||
toaster.dismiss(childToastId)
|
|
||||||
toastBySession.delete(childKey)
|
|
||||||
alertedAtBySession.delete(childKey)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (e.details?.type === "worktree.failed") {
|
||||||
|
setBusy(e.name, false)
|
||||||
|
WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||||
|
const title =
|
||||||
|
e.details.type === "permission.asked"
|
||||||
|
? language.t("notification.permission.title")
|
||||||
|
: language.t("notification.question.title")
|
||||||
|
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
|
||||||
|
const directory = e.name
|
||||||
|
const props = e.details.properties
|
||||||
|
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||||
|
|
||||||
|
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||||
|
const session = store.session.find((s) => s.id === props.sessionID)
|
||||||
|
const sessionKey = `${directory}:${props.sessionID}`
|
||||||
|
|
||||||
|
const sessionTitle = session?.title ?? language.t("command.session.new")
|
||||||
|
const projectName = getFilename(directory)
|
||||||
|
const description =
|
||||||
|
e.details.type === "permission.asked"
|
||||||
|
? language.t("notification.permission.description", { sessionTitle, projectName })
|
||||||
|
: language.t("notification.question.description", { sessionTitle, projectName })
|
||||||
|
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
|
||||||
|
if (now - lastAlerted < cooldownMs) return
|
||||||
|
alertedAtBySession.set(sessionKey, now)
|
||||||
|
|
||||||
|
if (e.details.type === "permission.asked") {
|
||||||
|
playSound(soundSrc(settings.sounds.permissions()))
|
||||||
|
if (settings.notifications.permissions()) {
|
||||||
|
void platform.notify(title, description, href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.details.type === "question.asked") {
|
||||||
|
if (settings.notifications.agent()) {
|
||||||
|
void platform.notify(title, description, href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSession = params.id
|
||||||
|
if (directory === currentDir() && props.sessionID === currentSession) return
|
||||||
|
if (directory === currentDir() && session?.parentID === currentSession) return
|
||||||
|
|
||||||
|
dismissSessionAlert(sessionKey)
|
||||||
|
|
||||||
|
const toastId = showToast({
|
||||||
|
persistent: true,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: language.t("notification.action.goToSession"),
|
||||||
|
onClick: () => navigate(href),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: language.t("common.dismiss"),
|
||||||
|
onClick: "dismiss",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
toastBySession.set(sessionKey, toastId)
|
||||||
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentSession = params.id
|
||||||
|
if (!currentDir() || !currentSession) return
|
||||||
|
const sessionKey = `${currentDir()}:${currentSession}`
|
||||||
|
dismissSessionAlert(sessionKey)
|
||||||
|
const [store] = globalSync.child(currentDir(), { bootstrap: false })
|
||||||
|
const childSessions = store.session.filter((s) => s.parentID === currentSession)
|
||||||
|
for (const child of childSessions) {
|
||||||
|
dismissSessionAlert(`${currentDir()}:${child.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
useUpdatePolling()
|
||||||
|
useSDKNotificationToasts()
|
||||||
|
|
||||||
function scrollToSession(sessionId: string, sessionKey: string) {
|
function scrollToSession(sessionId: string, sessionKey: string) {
|
||||||
if (!scrollContainerRef) return
|
if (!scrollContainerRef) return
|
||||||
@@ -641,6 +654,21 @@ export default function Layout(props: ParentProps) {
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
|
||||||
|
if (current.length === 0) {
|
||||||
|
return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string, T>()
|
||||||
|
for (const item of current) {
|
||||||
|
map.set(item.id, item)
|
||||||
|
}
|
||||||
|
for (const item of incoming) {
|
||||||
|
map.set(item.id, item)
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||||
|
}
|
||||||
|
|
||||||
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||||
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||||
|
|
||||||
@@ -649,51 +677,24 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (prefetchToken.value !== token) return
|
if (prefetchToken.value !== token) return
|
||||||
|
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const next = items
|
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||||
.map((x) => x.info)
|
const sorted = mergeByID([], next)
|
||||||
.filter((m) => !!m?.id)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
||||||
|
|
||||||
const current = store.message[sessionID] ?? []
|
const current = store.message[sessionID] ?? []
|
||||||
const merged = (() => {
|
const merged = mergeByID(
|
||||||
if (current.length === 0) return next
|
current.filter((item): item is Message => !!item?.id),
|
||||||
|
sorted,
|
||||||
const map = new Map<string, Message>()
|
)
|
||||||
for (const item of current) {
|
|
||||||
if (!item?.id) continue
|
|
||||||
map.set(item.id, item)
|
|
||||||
}
|
|
||||||
for (const item of next) {
|
|
||||||
map.set(item.id, item)
|
|
||||||
}
|
|
||||||
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
||||||
})()
|
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
setStore("message", sessionID, reconcile(merged, { key: "id" }))
|
||||||
|
|
||||||
for (const message of items) {
|
for (const message of items) {
|
||||||
const currentParts = store.part[message.info.id] ?? []
|
const currentParts = store.part[message.info.id] ?? []
|
||||||
const mergedParts = (() => {
|
const mergedParts = mergeByID(
|
||||||
if (currentParts.length === 0) {
|
currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
|
||||||
return message.parts
|
message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
|
||||||
.filter((p) => !!p?.id)
|
)
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map<string, (typeof currentParts)[number]>()
|
|
||||||
for (const item of currentParts) {
|
|
||||||
if (!item?.id) continue
|
|
||||||
map.set(item.id, item)
|
|
||||||
}
|
|
||||||
for (const item of message.parts) {
|
|
||||||
if (!item?.id) continue
|
|
||||||
map.set(item.id, item)
|
|
||||||
}
|
|
||||||
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
||||||
})()
|
|
||||||
|
|
||||||
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
|
||||||
}
|
}
|
||||||
@@ -1073,24 +1074,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
function navigateToProject(directory: string | undefined) {
|
function navigateToProject(directory: string | undefined) {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
if (!layout.sidebar.opened()) {
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setState("hoverProject", undefined)
|
|
||||||
}
|
|
||||||
server.projects.touch(directory)
|
server.projects.touch(directory)
|
||||||
const lastSession = store.lastSession[directory]
|
const lastSession = store.lastSession[directory]
|
||||||
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
|
||||||
layout.mobileSidebar.hide()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToSession(session: Session | undefined) {
|
function navigateToSession(session: Session | undefined) {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (!layout.sidebar.opened()) {
|
navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setState("hoverProject", undefined)
|
|
||||||
}
|
|
||||||
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
|
|
||||||
layout.mobileSidebar.hide()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openProject(directory: string, navigate = true) {
|
function openProject(directory: string, navigate = true) {
|
||||||
@@ -1555,10 +1546,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createWorkspace = async (project: LocalProject) => {
|
const createWorkspace = async (project: LocalProject) => {
|
||||||
if (!layout.sidebar.opened()) {
|
clearSidebarHoverState()
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setState("hoverProject", undefined)
|
|
||||||
}
|
|
||||||
const created = await globalSDK.client.worktree
|
const created = await globalSDK.client.worktree
|
||||||
.create({ directory: project.worktree })
|
.create({ directory: project.worktree })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
@@ -1595,8 +1583,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
globalSync.child(created.directory)
|
globalSync.child(created.directory)
|
||||||
navigate(`/${base64Encode(created.directory)}/session`)
|
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
|
||||||
layout.mobileSidebar.hide()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceSidebarCtx: WorkspaceSidebarContext = {
|
const workspaceSidebarCtx: WorkspaceSidebarContext = {
|
||||||
@@ -1772,14 +1759,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
size="large"
|
size="large"
|
||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={() => {
|
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||||
if (!layout.sidebar.opened()) {
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setState("hoverProject", undefined)
|
|
||||||
}
|
|
||||||
navigate(`/${base64Encode(p().worktree)}/session`)
|
|
||||||
layout.mobileSidebar.hide()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{language.t("command.session.new")}
|
{language.t("command.session.new")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Show, type Accessor } from "solid-js"
|
import { onCleanup, Show, type Accessor } from "solid-js"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
|
|
||||||
export function createInlineEditorController() {
|
export function createInlineEditorController() {
|
||||||
|
// This controller intentionally supports one active inline editor at a time.
|
||||||
const [editor, setEditor] = createStore({
|
const [editor, setEditor] = createStore({
|
||||||
active: "" as string,
|
active: "" as string,
|
||||||
value: "",
|
value: "",
|
||||||
@@ -47,6 +48,13 @@ export function createInlineEditorController() {
|
|||||||
stopPropagation?: boolean
|
stopPropagation?: boolean
|
||||||
openOnDblClick?: boolean
|
openOnDblClick?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
|
let frame: number | undefined
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (frame === undefined) return
|
||||||
|
cancelAnimationFrame(frame)
|
||||||
|
})
|
||||||
|
|
||||||
const isEditing = () => props.editing ?? editorOpen(props.id)
|
const isEditing = () => props.editing ?? editorOpen(props.id)
|
||||||
const stopEvents = () => props.stopPropagation ?? false
|
const stopEvents = () => props.stopPropagation ?? false
|
||||||
const allowDblClick = () => props.openOnDblClick ?? true
|
const allowDblClick = () => props.openOnDblClick ?? true
|
||||||
@@ -78,7 +86,12 @@ export function createInlineEditorController() {
|
|||||||
>
|
>
|
||||||
<InlineInput
|
<InlineInput
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
requestAnimationFrame(() => el.focus())
|
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = undefined
|
||||||
|
if (!el.isConnected) return
|
||||||
|
el.focus()
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
value={editorValue()}
|
value={editorValue()}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { MessageNav } from "@opencode-ai/ui/message-nav"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client"
|
import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
|
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||||
import { agentColor } from "@/utils/agent"
|
import { agentColor } from "@/utils/agent"
|
||||||
|
|
||||||
@@ -70,6 +70,116 @@ export type SessionItemProps = {
|
|||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SessionRow = (props: {
|
||||||
|
session: Session
|
||||||
|
slug: string
|
||||||
|
mobile?: boolean
|
||||||
|
dense?: boolean
|
||||||
|
tint: Accessor<string | undefined>
|
||||||
|
isWorking: Accessor<boolean>
|
||||||
|
hasPermissions: Accessor<boolean>
|
||||||
|
hasError: Accessor<boolean>
|
||||||
|
unseenCount: Accessor<number>
|
||||||
|
setHoverSession: (id: string | undefined) => void
|
||||||
|
clearHoverProjectSoon: () => void
|
||||||
|
sidebarOpened: Accessor<boolean>
|
||||||
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
|
scheduleHoverPrefetch: () => void
|
||||||
|
cancelHoverPrefetch: () => void
|
||||||
|
}): JSX.Element => (
|
||||||
|
<A
|
||||||
|
href={`/${props.slug}/session/${props.session.id}`}
|
||||||
|
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
|
onPointerEnter={props.scheduleHoverPrefetch}
|
||||||
|
onPointerLeave={props.cancelHoverPrefetch}
|
||||||
|
onMouseEnter={props.scheduleHoverPrefetch}
|
||||||
|
onMouseLeave={props.cancelHoverPrefetch}
|
||||||
|
onFocus={() => props.prefetchSession(props.session, "high")}
|
||||||
|
onClick={() => {
|
||||||
|
props.setHoverSession(undefined)
|
||||||
|
if (props.sidebarOpened()) return
|
||||||
|
props.clearHoverProjectSoon()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 w-full">
|
||||||
|
<div
|
||||||
|
class="shrink-0 size-6 flex items-center justify-center"
|
||||||
|
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||||
|
>
|
||||||
|
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||||
|
<Match when={props.isWorking()}>
|
||||||
|
<Spinner class="size-[15px]" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.hasPermissions()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.hasError()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.unseenCount() > 0}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||||
|
{props.session.title}
|
||||||
|
</span>
|
||||||
|
<Show when={props.session.summary}>
|
||||||
|
{(summary) => (
|
||||||
|
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||||
|
<DiffChanges changes={summary()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</A>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SessionHoverPreview = (props: {
|
||||||
|
mobile?: boolean
|
||||||
|
nav: Accessor<HTMLElement | undefined>
|
||||||
|
hoverSession: Accessor<string | undefined>
|
||||||
|
session: Session
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
hoverReady: Accessor<boolean>
|
||||||
|
hoverMessages: Accessor<UserMessage[] | undefined>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
isActive: Accessor<boolean>
|
||||||
|
slug: string
|
||||||
|
setHoverSession: (id: string | undefined) => void
|
||||||
|
messageLabel: (message: Message) => string | undefined
|
||||||
|
onMessageSelect: (message: Message) => void
|
||||||
|
trigger: JSX.Element
|
||||||
|
}): JSX.Element => (
|
||||||
|
<HoverCard
|
||||||
|
openDelay={1000}
|
||||||
|
closeDelay={props.sidebarHovering() ? 600 : 0}
|
||||||
|
placement="right-start"
|
||||||
|
gutter={16}
|
||||||
|
shift={-2}
|
||||||
|
trigger={props.trigger}
|
||||||
|
mount={!props.mobile ? props.nav() : undefined}
|
||||||
|
open={props.hoverSession() === props.session.id}
|
||||||
|
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.hoverReady()}
|
||||||
|
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
||||||
|
>
|
||||||
|
<div class="overflow-y-auto max-h-72 h-full">
|
||||||
|
<MessageNav
|
||||||
|
messages={props.hoverMessages() ?? []}
|
||||||
|
current={undefined}
|
||||||
|
getLabel={props.messageLabel}
|
||||||
|
onMessageSelect={props.onMessageSelect}
|
||||||
|
size="normal"
|
||||||
|
class="w-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</HoverCard>
|
||||||
|
)
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -113,7 +223,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hoverMessages = createMemo(() =>
|
const hoverMessages = createMemo(() =>
|
||||||
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
|
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
||||||
)
|
)
|
||||||
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
||||||
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
||||||
@@ -141,54 +251,24 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
|
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
|
||||||
return text?.text
|
return text?.text
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = (
|
const item = (
|
||||||
<A
|
<SessionRow
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
session={props.session}
|
||||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
slug={props.slug}
|
||||||
onPointerEnter={scheduleHoverPrefetch}
|
mobile={props.mobile}
|
||||||
onPointerLeave={cancelHoverPrefetch}
|
dense={props.dense}
|
||||||
onMouseEnter={scheduleHoverPrefetch}
|
tint={tint}
|
||||||
onMouseLeave={cancelHoverPrefetch}
|
isWorking={isWorking}
|
||||||
onFocus={() => props.prefetchSession(props.session, "high")}
|
hasPermissions={hasPermissions}
|
||||||
onClick={() => {
|
hasError={hasError}
|
||||||
props.setHoverSession(undefined)
|
unseenCount={unseenCount}
|
||||||
if (layout.sidebar.opened()) return
|
setHoverSession={props.setHoverSession}
|
||||||
props.clearHoverProjectSoon()
|
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||||
}}
|
sidebarOpened={layout.sidebar.opened}
|
||||||
>
|
prefetchSession={props.prefetchSession}
|
||||||
<div class="flex items-center gap-1 w-full">
|
scheduleHoverPrefetch={scheduleHoverPrefetch}
|
||||||
<div
|
cancelHoverPrefetch={cancelHoverPrefetch}
|
||||||
class="shrink-0 size-6 flex items-center justify-center"
|
/>
|
||||||
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
|
|
||||||
>
|
|
||||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
|
||||||
<Match when={isWorking()}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Match>
|
|
||||||
<Match when={hasPermissions()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
|
||||||
</Match>
|
|
||||||
<Match when={hasError()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
|
||||||
</Match>
|
|
||||||
<Match when={unseenCount() > 0}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
|
||||||
{props.session.title}
|
|
||||||
</span>
|
|
||||||
<Show when={props.session.summary}>
|
|
||||||
{(summary) => (
|
|
||||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
|
||||||
<DiffChanges changes={summary()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</A>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,44 +285,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HoverCard
|
<SessionHoverPreview
|
||||||
openDelay={1000}
|
mobile={props.mobile}
|
||||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
nav={props.nav}
|
||||||
placement="right-start"
|
hoverSession={props.hoverSession}
|
||||||
gutter={16}
|
session={props.session}
|
||||||
shift={-2}
|
sidebarHovering={props.sidebarHovering}
|
||||||
|
hoverReady={hoverReady}
|
||||||
|
hoverMessages={hoverMessages}
|
||||||
|
language={language}
|
||||||
|
isActive={isActive}
|
||||||
|
slug={props.slug}
|
||||||
|
setHoverSession={props.setHoverSession}
|
||||||
|
messageLabel={messageLabel}
|
||||||
|
onMessageSelect={(message) => {
|
||||||
|
if (!isActive()) {
|
||||||
|
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||||
|
navigate(`${props.slug}/session/${props.session.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, "", `#message-${message.id}`)
|
||||||
|
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
||||||
|
}}
|
||||||
trigger={item}
|
trigger={item}
|
||||||
mount={!props.mobile ? props.nav() : undefined}
|
/>
|
||||||
open={props.hoverSession() === props.session.id}
|
|
||||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={hoverReady()}
|
|
||||||
fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
|
|
||||||
>
|
|
||||||
<div class="overflow-y-auto max-h-72 h-full">
|
|
||||||
<MessageNav
|
|
||||||
messages={hoverMessages() ?? []}
|
|
||||||
current={undefined}
|
|
||||||
getLabel={messageLabel}
|
|
||||||
onMessageSelect={(message) => {
|
|
||||||
if (!isActive()) {
|
|
||||||
layout.pendingMessage.set(
|
|
||||||
`${base64Encode(props.session.directory)}/${props.session.id}`,
|
|
||||||
message.id,
|
|
||||||
)
|
|
||||||
navigate(`${props.slug}/session/${props.session.id}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.history.replaceState(null, "", `#message-${message.id}`)
|
|
||||||
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
|
||||||
}}
|
|
||||||
size="normal"
|
|
||||||
class="w-60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</HoverCard>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||||
|
|||||||
@@ -51,6 +51,195 @@ export const ProjectDragOverlay = (props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ProjectTile = (props: {
|
||||||
|
project: LocalProject
|
||||||
|
mobile?: boolean
|
||||||
|
nav: Accessor<HTMLElement | undefined>
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
selected: Accessor<boolean>
|
||||||
|
active: Accessor<boolean>
|
||||||
|
overlay: Accessor<boolean>
|
||||||
|
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||||
|
onProjectMouseLeave: (worktree: string) => void
|
||||||
|
onProjectFocus: (worktree: string) => void
|
||||||
|
navigateToProject: (directory: string) => void
|
||||||
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
|
closeProject: (directory: string) => void
|
||||||
|
setMenu: (value: boolean) => void
|
||||||
|
setOpen: (value: boolean) => void
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}): JSX.Element => (
|
||||||
|
<ContextMenu
|
||||||
|
modal={!props.sidebarHovering()}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
props.setMenu(value)
|
||||||
|
if (value) props.setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu.Trigger
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
aria-label={displayName(props.project)}
|
||||||
|
data-action="project-switch"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
classList={{
|
||||||
|
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
|
||||||
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
|
!props.selected() && !props.active(),
|
||||||
|
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||||
|
}}
|
||||||
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
|
if (!props.overlay()) return
|
||||||
|
props.onProjectMouseEnter(props.project.worktree, event)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (!props.overlay()) return
|
||||||
|
props.onProjectMouseLeave(props.project.worktree)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!props.overlay()) return
|
||||||
|
props.onProjectFocus(props.project.worktree)
|
||||||
|
}}
|
||||||
|
onClick={() => props.navigateToProject(props.project.worktree)}
|
||||||
|
onBlur={() => props.setOpen(false)}
|
||||||
|
>
|
||||||
|
<ProjectIcon project={props.project} notify />
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||||
|
<ContextMenu.Content>
|
||||||
|
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||||
|
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Item
|
||||||
|
data-action="project-workspaces-toggle"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
||||||
|
onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemLabel>
|
||||||
|
{props.workspacesEnabled(props.project)
|
||||||
|
? props.language.t("sidebar.workspaces.disable")
|
||||||
|
: props.language.t("sidebar.workspaces.enable")}
|
||||||
|
</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator />
|
||||||
|
<ContextMenu.Item
|
||||||
|
data-action="project-close-menu"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
onSelect={() => props.closeProject(props.project.worktree)}
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Portal>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProjectPreviewPanel = (props: {
|
||||||
|
project: LocalProject
|
||||||
|
mobile?: boolean
|
||||||
|
selected: Accessor<boolean>
|
||||||
|
workspaceEnabled: Accessor<boolean>
|
||||||
|
workspaces: Accessor<string[]>
|
||||||
|
label: (directory: string) => string
|
||||||
|
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
||||||
|
projectChildren: Accessor<Map<string, string[]>>
|
||||||
|
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||||
|
workspaceChildren: (directory: string) => Map<string, string[]>
|
||||||
|
setOpen: (value: boolean) => void
|
||||||
|
ctx: ProjectSidebarContext
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}): JSX.Element => (
|
||||||
|
<div class="-m-3 p-2 flex flex-col w-72">
|
||||||
|
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
||||||
|
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
||||||
|
<Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
|
||||||
|
<IconButton
|
||||||
|
icon="circle-x"
|
||||||
|
variant="ghost"
|
||||||
|
class="shrink-0"
|
||||||
|
data-action="project-close-hover"
|
||||||
|
data-project={base64Encode(props.project.worktree)}
|
||||||
|
aria-label={props.language.t("common.close")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
props.setOpen(false)
|
||||||
|
props.ctx.closeProject(props.project.worktree)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
||||||
|
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||||
|
<Show
|
||||||
|
when={props.workspaceEnabled()}
|
||||||
|
fallback={
|
||||||
|
<For each={props.projectSessions()}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionItem
|
||||||
|
{...props.ctx.sessionProps}
|
||||||
|
session={session}
|
||||||
|
slug={base64Encode(props.project.worktree)}
|
||||||
|
dense
|
||||||
|
mobile={props.mobile}
|
||||||
|
popover={false}
|
||||||
|
children={props.projectChildren()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={props.workspaces()}>
|
||||||
|
{(directory) => {
|
||||||
|
const sessions = createMemo(() => props.workspaceSessions(directory))
|
||||||
|
const children = createMemo(() => props.workspaceChildren(directory))
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
||||||
|
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||||
|
<Icon name="branch" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
|
||||||
|
</div>
|
||||||
|
<For each={sessions()}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionItem
|
||||||
|
{...props.ctx.sessionProps}
|
||||||
|
session={session}
|
||||||
|
slug={base64Encode(directory)}
|
||||||
|
dense
|
||||||
|
mobile={props.mobile}
|
||||||
|
popover={false}
|
||||||
|
children={children()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||||
|
onClick={() => {
|
||||||
|
props.ctx.openSidebar()
|
||||||
|
props.setOpen(false)
|
||||||
|
if (props.selected()) return
|
||||||
|
props.ctx.navigateToProject(props.project.worktree)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.language.t("sidebar.project.viewAllSessions")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
export const SortableProject = (props: {
|
export const SortableProject = (props: {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
@@ -105,177 +294,61 @@ export const SortableProject = (props: {
|
|||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||||
return childMapByParent(data.session)
|
return childMapByParent(data.session)
|
||||||
}
|
}
|
||||||
|
const trigger = (
|
||||||
const Trigger = () => (
|
<ProjectTile
|
||||||
<ContextMenu
|
project={props.project}
|
||||||
modal={!props.ctx.sidebarHovering()}
|
mobile={props.mobile}
|
||||||
onOpenChange={(value) => {
|
nav={props.ctx.nav}
|
||||||
setMenu(value)
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
if (value) setOpen(false)
|
selected={selected}
|
||||||
}}
|
active={active}
|
||||||
>
|
overlay={overlay}
|
||||||
<ContextMenu.Trigger
|
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
||||||
as="button"
|
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
||||||
type="button"
|
onProjectFocus={props.ctx.onProjectFocus}
|
||||||
aria-label={displayName(props.project)}
|
navigateToProject={props.ctx.navigateToProject}
|
||||||
data-action="project-switch"
|
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
||||||
data-project={base64Encode(props.project.worktree)}
|
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||||
classList={{
|
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||||
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
closeProject={props.ctx.closeProject}
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
|
setMenu={setMenu}
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
setOpen={setOpen}
|
||||||
!selected() && !active(),
|
language={language}
|
||||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
/>
|
||||||
}}
|
|
||||||
onMouseEnter={(event: MouseEvent) => {
|
|
||||||
if (!overlay()) return
|
|
||||||
props.ctx.onProjectMouseEnter(props.project.worktree, event)
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!overlay()) return
|
|
||||||
props.ctx.onProjectMouseLeave(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (!overlay()) return
|
|
||||||
props.ctx.onProjectFocus(props.project.worktree)
|
|
||||||
}}
|
|
||||||
onClick={() => props.ctx.navigateToProject(props.project.worktree)}
|
|
||||||
onBlur={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<ProjectIcon project={props.project} notify />
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
|
|
||||||
<ContextMenu.Content>
|
|
||||||
<ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
|
|
||||||
<ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-workspaces-toggle"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
|
|
||||||
onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>
|
|
||||||
{props.ctx.workspacesEnabled(props.project)
|
|
||||||
? language.t("sidebar.workspaces.disable")
|
|
||||||
: language.t("sidebar.workspaces.enable")}
|
|
||||||
</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Separator />
|
|
||||||
<ContextMenu.Item
|
|
||||||
data-action="project-close-menu"
|
|
||||||
data-project={base64Encode(props.project.worktree)}
|
|
||||||
onSelect={() => props.ctx.closeProject(props.project.worktree)}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Portal>
|
|
||||||
</ContextMenu>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
<Show when={preview()} fallback={<Trigger />}>
|
<Show when={preview()} fallback={trigger}>
|
||||||
<HoverCard
|
<HoverCard
|
||||||
open={open() && !menu()}
|
open={open() && !menu()}
|
||||||
openDelay={0}
|
openDelay={0}
|
||||||
closeDelay={0}
|
closeDelay={0}
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={6}
|
gutter={6}
|
||||||
trigger={<Trigger />}
|
trigger={trigger}
|
||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
if (menu()) return
|
if (menu()) return
|
||||||
setOpen(value)
|
setOpen(value)
|
||||||
if (value) props.ctx.setHoverSession(undefined)
|
if (value) props.ctx.setHoverSession(undefined)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="-m-3 p-2 flex flex-col w-72">
|
<ProjectPreviewPanel
|
||||||
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
project={props.project}
|
||||||
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
mobile={props.mobile}
|
||||||
<Tooltip value={language.t("common.close")} placement="top" gutter={6}>
|
selected={selected}
|
||||||
<IconButton
|
workspaceEnabled={workspaceEnabled}
|
||||||
icon="circle-x"
|
workspaces={workspaces}
|
||||||
variant="ghost"
|
label={label}
|
||||||
class="shrink-0"
|
projectSessions={projectSessions}
|
||||||
data-action="project-close-hover"
|
projectChildren={projectChildren}
|
||||||
data-project={base64Encode(props.project.worktree)}
|
workspaceSessions={workspaceSessions}
|
||||||
aria-label={language.t("common.close")}
|
workspaceChildren={workspaceChildren}
|
||||||
onClick={(event) => {
|
setOpen={setOpen}
|
||||||
event.stopPropagation()
|
ctx={props.ctx}
|
||||||
setOpen(false)
|
language={language}
|
||||||
props.ctx.closeProject(props.project.worktree)
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
|
|
||||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={workspaceEnabled()}
|
|
||||||
fallback={
|
|
||||||
<For each={projectSessions()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...props.ctx.sessionProps}
|
|
||||||
session={session}
|
|
||||||
slug={base64Encode(props.project.worktree)}
|
|
||||||
dense
|
|
||||||
mobile={props.mobile}
|
|
||||||
popover={false}
|
|
||||||
children={projectChildren()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={workspaces()}>
|
|
||||||
{(directory) => {
|
|
||||||
const sessions = createMemo(() => workspaceSessions(directory))
|
|
||||||
const children = createMemo(() => workspaceChildren(directory))
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
|
||||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
|
||||||
<Icon name="branch" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-14-medium text-text-base">{label(directory)}</span>
|
|
||||||
</div>
|
|
||||||
<For each={sessions()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...props.ctx.sessionProps}
|
|
||||||
session={session}
|
|
||||||
slug={base64Encode(directory)}
|
|
||||||
dense
|
|
||||||
mobile={props.mobile}
|
|
||||||
popover={false}
|
|
||||||
children={children()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
|
||||||
onClick={() => {
|
|
||||||
props.ctx.openSidebar()
|
|
||||||
setOpen(false)
|
|
||||||
if (selected()) return
|
|
||||||
props.ctx.navigateToProject(props.project.worktree)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("sidebar.project.viewAllSessions")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const SidebarContent = (props: {
|
|||||||
renderPanel: () => JSX.Element
|
renderPanel: () => JSX.Element
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
||||||
|
const placement = () => (props.mobile ? "bottom" : "right")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full overflow-hidden">
|
<div class="flex h-full w-full overflow-hidden">
|
||||||
@@ -55,7 +56,7 @@ export const SidebarContent = (props: {
|
|||||||
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
placement={placement()}
|
||||||
value={
|
value={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>{props.openProjectLabel}</span>
|
<span>{props.openProjectLabel}</span>
|
||||||
@@ -78,11 +79,7 @@ export const SidebarContent = (props: {
|
|||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
|
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
|
||||||
<TooltipKeybind
|
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
|
||||||
title={props.settingsLabel()}
|
|
||||||
keybind={props.settingsKeybind() ?? ""}
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="settings-gear"
|
icon="settings-gear"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -91,7 +88,7 @@ export const SidebarContent = (props: {
|
|||||||
aria-label={props.settingsLabel()}
|
aria-label={props.settingsLabel()}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}>
|
<Tooltip placement={placement()} value={props.helpLabel()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="help"
|
icon="help"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -82,6 +82,222 @@ export const WorkspaceDragOverlay = (props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WorkspaceHeader = (props: {
|
||||||
|
local: Accessor<boolean>
|
||||||
|
busy: Accessor<boolean>
|
||||||
|
open: Accessor<boolean>
|
||||||
|
directory: string
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
branch: Accessor<string | undefined>
|
||||||
|
workspaceValue: Accessor<string>
|
||||||
|
workspaceEditActive: Accessor<boolean>
|
||||||
|
InlineEditor: WorkspaceSidebarContext["InlineEditor"]
|
||||||
|
renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
|
||||||
|
setEditor: WorkspaceSidebarContext["setEditor"]
|
||||||
|
projectId?: string
|
||||||
|
}): JSX.Element => (
|
||||||
|
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-center shrink-0 size-6">
|
||||||
|
<Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
|
||||||
|
<Spinner class="size-[15px]" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<span class="text-14-medium text-text-base shrink-0">
|
||||||
|
{props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
|
||||||
|
</span>
|
||||||
|
<Show
|
||||||
|
when={!props.local()}
|
||||||
|
fallback={
|
||||||
|
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||||
|
{props.branch() ?? getFilename(props.directory)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<props.InlineEditor
|
||||||
|
id={`workspace:${props.directory}`}
|
||||||
|
value={props.workspaceValue}
|
||||||
|
onSave={(next) => {
|
||||||
|
const trimmed = next.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
|
||||||
|
props.setEditor("value", props.workspaceValue())
|
||||||
|
}}
|
||||||
|
class="text-14-medium text-text-base min-w-0 truncate"
|
||||||
|
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||||
|
editing={props.workspaceEditActive()}
|
||||||
|
stopPropagation={false}
|
||||||
|
openOnDblClick={false}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
||||||
|
<Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const WorkspaceActions = (props: {
|
||||||
|
directory: string
|
||||||
|
local: Accessor<boolean>
|
||||||
|
busy: Accessor<boolean>
|
||||||
|
menuOpen: Accessor<boolean>
|
||||||
|
pendingRename: Accessor<boolean>
|
||||||
|
setMenuOpen: (open: boolean) => void
|
||||||
|
setPendingRename: (value: boolean) => void
|
||||||
|
sidebarHovering: Accessor<boolean>
|
||||||
|
mobile?: boolean
|
||||||
|
nav: Accessor<HTMLElement | undefined>
|
||||||
|
touch: Accessor<boolean>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
workspaceValue: Accessor<string>
|
||||||
|
openEditor: WorkspaceSidebarContext["openEditor"]
|
||||||
|
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||||
|
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||||
|
root: string
|
||||||
|
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
|
||||||
|
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||||
|
navigateToNewSession: () => void
|
||||||
|
}): JSX.Element => (
|
||||||
|
<div
|
||||||
|
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100 pointer-events-auto": props.menuOpen(),
|
||||||
|
"opacity-0 pointer-events-none": !props.menuOpen(),
|
||||||
|
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
|
||||||
|
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu
|
||||||
|
modal={!props.sidebarHovering()}
|
||||||
|
open={props.menuOpen()}
|
||||||
|
onOpenChange={(open) => props.setMenuOpen(open)}
|
||||||
|
>
|
||||||
|
<Tooltip value={props.language.t("common.moreOptions")} placement="top">
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="dot-grid"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
data-action="workspace-menu"
|
||||||
|
data-workspace={base64Encode(props.directory)}
|
||||||
|
aria-label={props.language.t("common.moreOptions")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (!props.pendingRename()) return
|
||||||
|
event.preventDefault()
|
||||||
|
props.setPendingRename(false)
|
||||||
|
props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={props.local()}
|
||||||
|
onSelect={() => {
|
||||||
|
props.setPendingRename(true)
|
||||||
|
props.setMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={props.local() || props.busy()}
|
||||||
|
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
disabled={props.local() || props.busy()}
|
||||||
|
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Show when={!props.touch()}>
|
||||||
|
<Tooltip value={props.language.t("command.session.new")} placement="top">
|
||||||
|
<IconButton
|
||||||
|
icon="plus-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
||||||
|
data-action="workspace-new-session"
|
||||||
|
data-workspace={base64Encode(props.directory)}
|
||||||
|
aria-label={props.language.t("command.session.new")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
props.setHoverSession(undefined)
|
||||||
|
props.clearHoverProjectSoon()
|
||||||
|
props.navigateToNewSession()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const WorkspaceSessionList = (props: {
|
||||||
|
slug: Accessor<string>
|
||||||
|
mobile?: boolean
|
||||||
|
ctx: WorkspaceSidebarContext
|
||||||
|
showNew: Accessor<boolean>
|
||||||
|
loading: Accessor<boolean>
|
||||||
|
sessions: Accessor<Session[]>
|
||||||
|
children: Accessor<Map<string, string[]>>
|
||||||
|
hasMore: Accessor<boolean>
|
||||||
|
loadMore: () => Promise<void>
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
}): JSX.Element => (
|
||||||
|
<nav class="flex flex-col gap-1 px-2">
|
||||||
|
<Show when={props.showNew()}>
|
||||||
|
<NewSessionItem
|
||||||
|
slug={props.slug()}
|
||||||
|
mobile={props.mobile}
|
||||||
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
setHoverSession={props.ctx.setHoverSession}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.loading()}>
|
||||||
|
<SessionSkeleton />
|
||||||
|
</Show>
|
||||||
|
<For each={props.sessions()}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionItem
|
||||||
|
session={session}
|
||||||
|
slug={props.slug()}
|
||||||
|
mobile={props.mobile}
|
||||||
|
children={props.children()}
|
||||||
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
|
nav={props.ctx.nav}
|
||||||
|
hoverSession={props.ctx.hoverSession}
|
||||||
|
setHoverSession={props.ctx.setHoverSession}
|
||||||
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
|
archiveSession={props.ctx.archiveSession}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={props.hasMore()}>
|
||||||
|
<div class="relative w-full py-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
||||||
|
size="large"
|
||||||
|
onClick={(e: MouseEvent) => {
|
||||||
|
props.loadMore()
|
||||||
|
;(e.currentTarget as HTMLButtonElement).blur()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.language.t("common.loadMore")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
|
||||||
export const SortableWorkspace = (props: {
|
export const SortableWorkspace = (props: {
|
||||||
ctx: WorkspaceSidebarContext
|
ctx: WorkspaceSidebarContext
|
||||||
directory: string
|
directory: string
|
||||||
@@ -135,46 +351,6 @@ export const SortableWorkspace = (props: {
|
|||||||
globalSync.child(props.directory, { bootstrap: true })
|
globalSync.child(props.directory, { bootstrap: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
const header = () => (
|
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
|
||||||
<div class="flex items-center justify-center shrink-0 size-6">
|
|
||||||
<Show when={busy()} fallback={<Icon name="branch" size="small" />}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<span class="text-14-medium text-text-base shrink-0">
|
|
||||||
{local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
|
|
||||||
</span>
|
|
||||||
<Show
|
|
||||||
when={!local()}
|
|
||||||
fallback={
|
|
||||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
|
||||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<props.ctx.InlineEditor
|
|
||||||
id={`workspace:${props.directory}`}
|
|
||||||
value={workspaceValue}
|
|
||||||
onSave={(next) => {
|
|
||||||
const trimmed = next.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
|
|
||||||
props.ctx.setEditor("value", workspaceValue())
|
|
||||||
}}
|
|
||||||
class="text-14-medium text-text-base min-w-0 truncate"
|
|
||||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
|
||||||
editing={workspaceEditActive()}
|
|
||||||
stopPropagation={false}
|
|
||||||
openOnDblClick={false}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
|
|
||||||
<Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -202,7 +378,20 @@ export const SortableWorkspace = (props: {
|
|||||||
data-action="workspace-toggle"
|
data-action="workspace-toggle"
|
||||||
data-workspace={base64Encode(props.directory)}
|
data-workspace={base64Encode(props.directory)}
|
||||||
>
|
>
|
||||||
{header()}
|
<WorkspaceHeader
|
||||||
|
local={local}
|
||||||
|
busy={busy}
|
||||||
|
open={open}
|
||||||
|
directory={props.directory}
|
||||||
|
language={language}
|
||||||
|
branch={() => workspaceStore.vcs?.branch}
|
||||||
|
workspaceValue={workspaceValue}
|
||||||
|
workspaceEditActive={workspaceEditActive}
|
||||||
|
InlineEditor={props.ctx.InlineEditor}
|
||||||
|
renameWorkspace={props.ctx.renameWorkspace}
|
||||||
|
setEditor={props.ctx.setEditor}
|
||||||
|
projectId={props.project.id}
|
||||||
|
/>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -211,139 +400,61 @@ export const SortableWorkspace = (props: {
|
|||||||
menu.open ? "pr-16" : "pr-2"
|
menu.open ? "pr-16" : "pr-2"
|
||||||
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
||||||
>
|
>
|
||||||
{header()}
|
<WorkspaceHeader
|
||||||
|
local={local}
|
||||||
|
busy={busy}
|
||||||
|
open={open}
|
||||||
|
directory={props.directory}
|
||||||
|
language={language}
|
||||||
|
branch={() => workspaceStore.vcs?.branch}
|
||||||
|
workspaceValue={workspaceValue}
|
||||||
|
workspaceEditActive={workspaceEditActive}
|
||||||
|
InlineEditor={props.ctx.InlineEditor}
|
||||||
|
renameWorkspace={props.ctx.renameWorkspace}
|
||||||
|
setEditor={props.ctx.setEditor}
|
||||||
|
projectId={props.project.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<WorkspaceActions
|
||||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
directory={props.directory}
|
||||||
classList={{
|
local={local}
|
||||||
"opacity-100 pointer-events-auto": menu.open,
|
busy={busy}
|
||||||
"opacity-0 pointer-events-none": !menu.open,
|
menuOpen={() => menu.open}
|
||||||
"group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
|
pendingRename={() => menu.pendingRename}
|
||||||
"group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
|
setMenuOpen={(open) => setMenu("open", open)}
|
||||||
}}
|
setPendingRename={(value) => setMenu("pendingRename", value)}
|
||||||
>
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
<DropdownMenu
|
mobile={props.mobile}
|
||||||
modal={!props.ctx.sidebarHovering()}
|
nav={props.ctx.nav}
|
||||||
open={menu.open}
|
touch={touch}
|
||||||
onOpenChange={(open) => setMenu("open", open)}
|
language={language}
|
||||||
>
|
workspaceValue={workspaceValue}
|
||||||
<Tooltip value={language.t("common.moreOptions")} placement="top">
|
openEditor={props.ctx.openEditor}
|
||||||
<DropdownMenu.Trigger
|
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||||
as={IconButton}
|
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||||
icon="dot-grid"
|
root={props.project.worktree}
|
||||||
variant="ghost"
|
setHoverSession={props.ctx.setHoverSession}
|
||||||
class="size-6 rounded-md"
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
data-action="workspace-menu"
|
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
||||||
data-workspace={base64Encode(props.directory)}
|
/>
|
||||||
aria-label={language.t("common.moreOptions")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (!menu.pendingRename) return
|
|
||||||
event.preventDefault()
|
|
||||||
setMenu("pendingRename", false)
|
|
||||||
props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={local()}
|
|
||||||
onSelect={() => {
|
|
||||||
setMenu("pendingRename", true)
|
|
||||||
setMenu("open", false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={local() || busy()}
|
|
||||||
onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={local() || busy()}
|
|
||||||
onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Show when={!touch()}>
|
|
||||||
<Tooltip value={language.t("command.session.new")} placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon="plus-small"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
|
|
||||||
data-action="workspace-new-session"
|
|
||||||
data-workspace={base64Encode(props.directory)}
|
|
||||||
aria-label={language.t("command.session.new")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
props.ctx.setHoverSession(undefined)
|
|
||||||
props.ctx.clearHoverProjectSoon()
|
|
||||||
navigate(`/${slug()}/session`)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
<nav class="flex flex-col gap-1 px-2">
|
<WorkspaceSessionList
|
||||||
<Show when={showNew()}>
|
slug={slug}
|
||||||
<NewSessionItem
|
mobile={props.mobile}
|
||||||
slug={slug()}
|
ctx={props.ctx}
|
||||||
mobile={props.mobile}
|
showNew={showNew}
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
loading={loading}
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
sessions={sessions}
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
children={children}
|
||||||
/>
|
hasMore={hasMore}
|
||||||
</Show>
|
loadMore={loadMore}
|
||||||
<Show when={loading()}>
|
language={language}
|
||||||
<SessionSkeleton />
|
/>
|
||||||
</Show>
|
|
||||||
<For each={sessions()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
session={session}
|
|
||||||
slug={slug()}
|
|
||||||
mobile={props.mobile}
|
|
||||||
children={children()}
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
nav={props.ctx.nav}
|
|
||||||
hoverSession={props.ctx.hoverSession}
|
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
|
||||||
archiveSession={props.ctx.archiveSession}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={hasMore()}>
|
|
||||||
<div class="relative w-full py-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
|
||||||
size="large"
|
|
||||||
onClick={(e: MouseEvent) => {
|
|
||||||
loadMore()
|
|
||||||
;(e.currentTarget as HTMLButtonElement).blur()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{language.t("common.loadMore")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</nav>
|
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,6 +394,19 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
|
if (params.id !== sessionID) return
|
||||||
|
if (parentID) {
|
||||||
|
navigate(`/${params.dir}/session/${parentID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextSessionID) {
|
||||||
|
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(`/${params.dir}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
async function archiveSession(sessionID: string) {
|
async function archiveSession(sessionID: string) {
|
||||||
const session = sync.session.get(sessionID)
|
const session = sync.session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
@@ -411,17 +424,7 @@ export default function Page() {
|
|||||||
if (index !== -1) draft.session.splice(index, 1)
|
if (index !== -1) draft.session.splice(index, 1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
if (params.id !== sessionID) return
|
|
||||||
if (session.parentID) {
|
|
||||||
navigate(`/${params.dir}/session/${session.parentID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextSession) {
|
|
||||||
navigate(`/${params.dir}/session/${nextSession.id}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate(`/${params.dir}/session`)
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -487,16 +490,7 @@ export default function Page() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (params.id !== sessionID) return true
|
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||||
if (session.parentID) {
|
|
||||||
navigate(`/${params.dir}/session/${session.parentID}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (nextSession) {
|
|
||||||
navigate(`/${params.dir}/session/${nextSession.id}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
navigate(`/${params.dir}/session`)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1532,15 +1526,18 @@ export default function Page() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!file.ready()) return
|
if (!file.ready()) return
|
||||||
setSessionHandoff(sessionKey(), {
|
setSessionHandoff(sessionKey(), {
|
||||||
files: Object.fromEntries(
|
files: tabs()
|
||||||
tabs()
|
.all()
|
||||||
.all()
|
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
|
||||||
.flatMap((tab) => {
|
const path = file.pathFromTab(tab)
|
||||||
const path = file.pathFromTab(tab)
|
if (!path) return acc
|
||||||
if (!path) return []
|
const selected = file.selectedLines(path)
|
||||||
return [[path, file.selectedLines(path) ?? null] as const]
|
acc[path] =
|
||||||
}),
|
selected && typeof selected === "object" && "start" in selected && "end" in selected
|
||||||
),
|
? (selected as SelectedLineRange)
|
||||||
|
: null
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1557,6 +1554,7 @@ export default function Page() {
|
|||||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||||
<SessionMobileTabs
|
<SessionMobileTabs
|
||||||
open={!isDesktop() && !!params.id}
|
open={!isDesktop() && !!params.id}
|
||||||
|
mobileTab={store.mobileTab}
|
||||||
hasReview={hasReview()}
|
hasReview={hasReview()}
|
||||||
reviewCount={reviewCount()}
|
reviewCount={reviewCount()}
|
||||||
onSession={() => setStore("mobileTab", "session")}
|
onSession={() => setStore("mobileTab", "session")}
|
||||||
@@ -1719,7 +1717,6 @@ export default function Page() {
|
|||||||
dialog={dialog}
|
dialog={dialog}
|
||||||
file={file}
|
file={file}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
sync={sync}
|
|
||||||
hasReview={hasReview()}
|
hasReview={hasReview()}
|
||||||
reviewCount={reviewCount()}
|
reviewCount={reviewCount()}
|
||||||
reviewTab={reviewTab()}
|
reviewTab={reviewTab()}
|
||||||
@@ -1731,10 +1728,12 @@ export default function Page() {
|
|||||||
openTab={openTab}
|
openTab={openTab}
|
||||||
showAllFiles={showAllFiles}
|
showAllFiles={showAllFiles}
|
||||||
reviewPanel={reviewPanel}
|
reviewPanel={reviewPanel}
|
||||||
messages={messages as () => unknown[]}
|
vm={{
|
||||||
visibleUserMessages={visibleUserMessages as () => unknown[]}
|
messages,
|
||||||
view={view}
|
visibleUserMessages,
|
||||||
info={info as () => unknown}
|
view,
|
||||||
|
info,
|
||||||
|
}}
|
||||||
handoffFiles={() => handoff.session.get(sessionKey())?.files}
|
handoffFiles={() => handoff.session.get(sessionKey())?.files}
|
||||||
codeComponent={codeComponent}
|
codeComponent={codeComponent}
|
||||||
addCommentToContext={addCommentToContext}
|
addCommentToContext={addCommentToContext}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import { useFile, type SelectedLineRange } from "@/context/file"
|
|||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
const formatCommentLabel = (range: SelectedLineRange) => {
|
||||||
|
const start = Math.min(range.start, range.end)
|
||||||
|
const end = Math.max(range.start, range.end)
|
||||||
|
if (start === end) return `line ${start}`
|
||||||
|
return `lines ${start}-${end}`
|
||||||
|
}
|
||||||
|
|
||||||
export function FileTabContent(props: {
|
export function FileTabContent(props: {
|
||||||
tab: string
|
tab: string
|
||||||
activeTab: () => string
|
activeTab: () => string
|
||||||
@@ -76,7 +83,6 @@ export function FileTabContent(props: {
|
|||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: props.language.t("toast.file.loadFailed.title"),
|
title: props.language.t("toast.file.loadFailed.title"),
|
||||||
description: "Invalid base64 content.",
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const svgPreviewUrl = createMemo(() => {
|
const svgPreviewUrl = createMemo(() => {
|
||||||
@@ -116,34 +122,6 @@ export function FileTabContent(props: {
|
|||||||
draftTop: undefined as number | undefined,
|
draftTop: undefined as number | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const openedComment = () => note.openedComment
|
|
||||||
const setOpenedComment = (
|
|
||||||
value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment),
|
|
||||||
) => setNote("openedComment", value)
|
|
||||||
|
|
||||||
const commenting = () => note.commenting
|
|
||||||
const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) =>
|
|
||||||
setNote("commenting", value)
|
|
||||||
|
|
||||||
const draft = () => note.draft
|
|
||||||
const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) =>
|
|
||||||
setNote("draft", value)
|
|
||||||
|
|
||||||
const positions = () => note.positions
|
|
||||||
const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) =>
|
|
||||||
setNote("positions", value)
|
|
||||||
|
|
||||||
const draftTop = () => note.draftTop
|
|
||||||
const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) =>
|
|
||||||
setNote("draftTop", value)
|
|
||||||
|
|
||||||
const commentLabel = (range: SelectedLineRange) => {
|
|
||||||
const start = Math.min(range.start, range.end)
|
|
||||||
const end = Math.max(range.start, range.end)
|
|
||||||
if (start === end) return `line ${start}`
|
|
||||||
return `lines ${start}-${end}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRoot = () => {
|
const getRoot = () => {
|
||||||
const el = wrap
|
const el = wrap
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -174,8 +152,8 @@ export function FileTabContent(props: {
|
|||||||
const el = wrap
|
const el = wrap
|
||||||
const root = getRoot()
|
const root = getRoot()
|
||||||
if (!el || !root) {
|
if (!el || !root) {
|
||||||
setPositions({})
|
setNote("positions", {})
|
||||||
setDraftTop(undefined)
|
setNote("draftTop", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,21 +164,21 @@ export function FileTabContent(props: {
|
|||||||
next[comment.id] = markerTop(el, marker)
|
next[comment.id] = markerTop(el, marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
setPositions(next)
|
setNote("positions", next)
|
||||||
|
|
||||||
const range = commenting()
|
const range = note.commenting
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setDraftTop(undefined)
|
setNote("draftTop", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const marker = findMarker(root, range)
|
const marker = findMarker(root, range)
|
||||||
if (!marker) {
|
if (!marker) {
|
||||||
setDraftTop(undefined)
|
setNote("draftTop", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraftTop(markerTop(el, marker))
|
setNote("draftTop", markerTop(el, marker))
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleComments = () => {
|
const scheduleComments = () => {
|
||||||
@@ -213,10 +191,10 @@ export function FileTabContent(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const range = commenting()
|
const range = note.commenting
|
||||||
scheduleComments()
|
scheduleComments()
|
||||||
if (!range) return
|
if (!range) return
|
||||||
setDraft("")
|
setNote("draft", "")
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -229,8 +207,8 @@ export function FileTabContent(props: {
|
|||||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
setOpenedComment(target.id)
|
setNote("openedComment", target.id)
|
||||||
setCommenting(null)
|
setNote("commenting", null)
|
||||||
props.file.setSelectedLines(p, target.selection)
|
props.file.setSelectedLines(p, target.selection)
|
||||||
requestAnimationFrame(() => props.comments.clearFocus())
|
requestAnimationFrame(() => props.comments.clearFocus())
|
||||||
})
|
})
|
||||||
@@ -390,16 +368,16 @@ export function FileTabContent(props: {
|
|||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
props.file.setSelectedLines(p, range)
|
props.file.setSelectedLines(p, range)
|
||||||
if (!range) setCommenting(null)
|
if (!range) setNote("commenting", null)
|
||||||
}}
|
}}
|
||||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setCommenting(null)
|
setNote("commenting", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenedComment(null)
|
setNote("openedComment", null)
|
||||||
setCommenting(range)
|
setNote("commenting", range)
|
||||||
}}
|
}}
|
||||||
overflow="scroll"
|
overflow="scroll"
|
||||||
class="select-text"
|
class="select-text"
|
||||||
@@ -408,10 +386,10 @@ export function FileTabContent(props: {
|
|||||||
{(comment) => (
|
{(comment) => (
|
||||||
<LineCommentView
|
<LineCommentView
|
||||||
id={comment.id}
|
id={comment.id}
|
||||||
top={positions()[comment.id]}
|
top={note.positions[comment.id]}
|
||||||
open={openedComment() === comment.id}
|
open={note.openedComment === comment.id}
|
||||||
comment={comment.comment}
|
comment={comment.comment}
|
||||||
selection={commentLabel(comment.selection)}
|
selection={formatCommentLabel(comment.selection)}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
@@ -420,22 +398,22 @@ export function FileTabContent(props: {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
setCommenting(null)
|
setNote("commenting", null)
|
||||||
setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||||
props.file.setSelectedLines(p, comment.selection)
|
props.file.setSelectedLines(p, comment.selection)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Show when={commenting()}>
|
<Show when={note.commenting}>
|
||||||
{(range) => (
|
{(range) => (
|
||||||
<Show when={draftTop() !== undefined}>
|
<Show when={note.draftTop !== undefined}>
|
||||||
<LineCommentEditor
|
<LineCommentEditor
|
||||||
top={draftTop()}
|
top={note.draftTop}
|
||||||
value={draft()}
|
value={note.draft}
|
||||||
selection={commentLabel(range())}
|
selection={formatCommentLabel(range())}
|
||||||
onInput={(value) => setDraft(value)}
|
onInput={(value) => setNote("draft", value)}
|
||||||
onCancel={() => setCommenting(null)}
|
onCancel={() => setNote("commenting", null)}
|
||||||
onSubmit={(value) => {
|
onSubmit={(value) => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return
|
if (!p) return
|
||||||
@@ -445,7 +423,7 @@ export function FileTabContent(props: {
|
|||||||
comment: value,
|
comment: value,
|
||||||
origin: "file",
|
origin: "file",
|
||||||
})
|
})
|
||||||
setCommenting(null)
|
setNote("commenting", null)
|
||||||
}}
|
}}
|
||||||
onPopoverFocusOut={(e: FocusEvent) => {
|
onPopoverFocusOut={(e: FocusEvent) => {
|
||||||
const current = e.currentTarget as HTMLDivElement
|
const current = e.currentTarget as HTMLDivElement
|
||||||
@@ -454,7 +432,7 @@ export function FileTabContent(props: {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||||
setCommenting(null)
|
setNote("commenting", null)
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -9,6 +9,37 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
|
|
||||||
|
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||||
|
const current = target instanceof Element ? target : undefined
|
||||||
|
const nested = current?.closest("[data-scrollable]")
|
||||||
|
if (!nested || nested === root) return root
|
||||||
|
if (!(nested instanceof HTMLElement)) return root
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
|
||||||
|
const markBoundaryGesture = (input: {
|
||||||
|
root: HTMLDivElement
|
||||||
|
target: EventTarget | null
|
||||||
|
delta: number
|
||||||
|
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||||
|
}) => {
|
||||||
|
const target = boundaryTarget(input.root, input.target)
|
||||||
|
if (target === input.root) {
|
||||||
|
input.onMarkScrollGesture(input.root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
shouldMarkBoundaryGesture({
|
||||||
|
delta: input.delta,
|
||||||
|
scrollTop: target.scrollTop,
|
||||||
|
scrollHeight: target.scrollHeight,
|
||||||
|
clientHeight: target.clientHeight,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
input.onMarkScrollGesture(input.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageTimeline(props: {
|
export function MessageTimeline(props: {
|
||||||
mobileChanges: boolean
|
mobileChanges: boolean
|
||||||
mobileFallback: JSX.Element
|
mobileFallback: JSX.Element
|
||||||
@@ -86,35 +117,13 @@ export function MessageTimeline(props: {
|
|||||||
ref={props.setScrollRef}
|
ref={props.setScrollRef}
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
const root = e.currentTarget
|
const root = e.currentTarget
|
||||||
const target = e.target instanceof Element ? e.target : undefined
|
|
||||||
const nested = target?.closest("[data-scrollable]")
|
|
||||||
if (!nested || nested === root) {
|
|
||||||
props.onMarkScrollGesture(root)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(nested instanceof HTMLElement)) {
|
|
||||||
props.onMarkScrollGesture(root)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = normalizeWheelDelta({
|
const delta = normalizeWheelDelta({
|
||||||
deltaY: e.deltaY,
|
deltaY: e.deltaY,
|
||||||
deltaMode: e.deltaMode,
|
deltaMode: e.deltaMode,
|
||||||
rootHeight: root.clientHeight,
|
rootHeight: root.clientHeight,
|
||||||
})
|
})
|
||||||
if (!delta) return
|
if (!delta) return
|
||||||
|
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||||
if (
|
|
||||||
shouldMarkBoundaryGesture({
|
|
||||||
delta,
|
|
||||||
scrollTop: nested.scrollTop,
|
|
||||||
scrollHeight: nested.scrollHeight,
|
|
||||||
clientHeight: nested.clientHeight,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
props.onMarkScrollGesture(root)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
touchGesture = e.touches[0]?.clientY
|
touchGesture = e.touches[0]?.clientY
|
||||||
@@ -129,28 +138,7 @@ export function MessageTimeline(props: {
|
|||||||
if (!delta) return
|
if (!delta) return
|
||||||
|
|
||||||
const root = e.currentTarget
|
const root = e.currentTarget
|
||||||
const target = e.target instanceof Element ? e.target : undefined
|
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||||
const nested = target?.closest("[data-scrollable]")
|
|
||||||
if (!nested || nested === root) {
|
|
||||||
props.onMarkScrollGesture(root)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(nested instanceof HTMLElement)) {
|
|
||||||
props.onMarkScrollGesture(root)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
shouldMarkBoundaryGesture({
|
|
||||||
delta,
|
|
||||||
scrollTop: nested.scrollTop,
|
|
||||||
scrollHeight: nested.scrollHeight,
|
|
||||||
clientHeight: nested.clientHeight,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
props.onMarkScrollGesture(root)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onTouchEnd={() => {
|
onTouchEnd={() => {
|
||||||
touchGesture = undefined
|
touchGesture = undefined
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
|
import { createEffect, on, onCleanup, type JSX } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
import type { SelectedLineRange } from "@/context/file"
|
import type { SelectedLineRange } from "@/context/file"
|
||||||
@@ -30,7 +31,7 @@ export interface SessionReviewTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StickyAddButton(props: { children: JSX.Element }) {
|
export function StickyAddButton(props: { children: JSX.Element }) {
|
||||||
const [stuck, setStuck] = createSignal(false)
|
const [state, setState] = createStore({ stuck: false })
|
||||||
let button: HTMLDivElement | undefined
|
let button: HTMLDivElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -43,7 +44,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
|
|||||||
const handler = () => {
|
const handler = () => {
|
||||||
const rect = node.getBoundingClientRect()
|
const rect = node.getBoundingClientRect()
|
||||||
const scrollRect = scroll.getBoundingClientRect()
|
const scrollRect = scroll.getBoundingClientRect()
|
||||||
setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
|
setState("stuck", rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
scroll.addEventListener("scroll", handler, { passive: true })
|
scroll.addEventListener("scroll", handler, { passive: true })
|
||||||
@@ -60,7 +61,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
|
|||||||
<div
|
<div
|
||||||
ref={button}
|
ref={button}
|
||||||
class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
|
class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
|
||||||
classList={{ "border-l": stuck() }}
|
classList={{ "border-l": state.stuck }}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +79,10 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
return sdk.client.file
|
return sdk.client.file
|
||||||
.read({ path })
|
.read({ path })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch((error) => {
|
||||||
|
console.debug("[session-review] failed to read file", { path, error })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreScroll = () => {
|
const restoreScroll = () => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Match, Show, Switch } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
|
||||||
export function SessionMobileTabs(props: {
|
export function SessionMobileTabs(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
mobileTab: "session" | "changes"
|
||||||
hasReview: boolean
|
hasReview: boolean
|
||||||
reviewCount: number
|
reviewCount: number
|
||||||
onSession: () => void
|
onSession: () => void
|
||||||
@@ -11,7 +12,7 @@ export function SessionMobileTabs(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Show when={props.open}>
|
<Show when={props.open}>
|
||||||
<Tabs class="h-auto">
|
<Tabs value={props.mobileTab} class="h-auto">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
|
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
|
||||||
{props.t("session.tab.session")}
|
{props.t("session.tab.session")}
|
||||||
@@ -22,12 +23,9 @@ export function SessionMobileTabs(props: {
|
|||||||
classes={{ button: "w-full" }}
|
classes={{ button: "w-full" }}
|
||||||
onClick={props.onChanges}
|
onClick={props.onChanges}
|
||||||
>
|
>
|
||||||
<Switch>
|
{props.hasReview
|
||||||
<Match when={props.hasReview}>
|
? props.t("session.review.filesChanged", { count: props.reviewCount })
|
||||||
{props.t("session.review.filesChanged", { count: props.reviewCount })}
|
: props.t("session.review.change.other")}
|
||||||
</Match>
|
|
||||||
<Match when={true}>{props.t("session.review.change.other")}</Match>
|
|
||||||
</Switch>
|
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { For, Show, type ComponentProps } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { BasicTool } from "@opencode-ai/ui/basic-tool"
|
import { BasicTool } from "@opencode-ai/ui/basic-tool"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { QuestionDock } from "@/components/question-dock"
|
import { QuestionDock } from "@/components/question-dock"
|
||||||
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
|
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
|
||||||
|
|
||||||
const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
|
|
||||||
|
|
||||||
export function SessionPromptDock(props: {
|
export function SessionPromptDock(props: {
|
||||||
centered: boolean
|
centered: boolean
|
||||||
questionRequest: () => { questions: unknown[] } | undefined
|
questionRequest: () => QuestionRequest | undefined
|
||||||
permissionRequest: () => { patterns: string[]; permission: string } | undefined
|
permissionRequest: () => { patterns: string[]; permission: string } | undefined
|
||||||
blocked: boolean
|
blocked: boolean
|
||||||
promptReady: boolean
|
promptReady: boolean
|
||||||
@@ -48,7 +47,7 @@ export function SessionPromptDock(props: {
|
|||||||
subtitle,
|
subtitle,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<QuestionDock request={questionDockRequest(req)} />
|
<QuestionDock request={req} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ import { useFile, type SelectedLineRange } from "@/context/file"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type SessionSidePanelViewModel = {
|
||||||
|
messages: () => Message[]
|
||||||
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
|
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -31,7 +39,6 @@ export function SessionSidePanel(props: {
|
|||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
file: ReturnType<typeof useFile>
|
file: ReturnType<typeof useFile>
|
||||||
comments: ReturnType<typeof useComments>
|
comments: ReturnType<typeof useComments>
|
||||||
sync: ReturnType<typeof useSync>
|
|
||||||
hasReview: boolean
|
hasReview: boolean
|
||||||
reviewCount: number
|
reviewCount: number
|
||||||
reviewTab: boolean
|
reviewTab: boolean
|
||||||
@@ -43,10 +50,7 @@ export function SessionSidePanel(props: {
|
|||||||
openTab: (value: string) => void
|
openTab: (value: string) => void
|
||||||
showAllFiles: () => void
|
showAllFiles: () => void
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
messages: () => unknown[]
|
vm: SessionSidePanelViewModel
|
||||||
visibleUserMessages: () => unknown[]
|
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
|
||||||
info: () => unknown
|
|
||||||
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
|
||||||
codeComponent: NonNullable<ValidComponent>
|
codeComponent: NonNullable<ValidComponent>
|
||||||
addCommentToContext: (input: {
|
addCommentToContext: (input: {
|
||||||
@@ -187,10 +191,10 @@ export function SessionSidePanel(props: {
|
|||||||
<Show when={props.activeTab() === "context"}>
|
<Show when={props.activeTab() === "context"}>
|
||||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<SessionContextTab
|
<SessionContextTab
|
||||||
messages={props.messages as never}
|
messages={props.vm.messages}
|
||||||
visibleUserMessages={props.visibleUserMessages as never}
|
visibleUserMessages={props.vm.visibleUserMessages}
|
||||||
view={props.view as never}
|
view={props.vm.view}
|
||||||
info={props.info as never}
|
info={props.vm.info}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -203,7 +207,7 @@ export function SessionSidePanel(props: {
|
|||||||
tab={tab}
|
tab={tab}
|
||||||
activeTab={props.activeTab}
|
activeTab={props.activeTab}
|
||||||
tabs={props.tabs}
|
tabs={props.tabs}
|
||||||
view={props.view}
|
view={props.vm.view}
|
||||||
handoffFiles={props.handoffFiles}
|
handoffFiles={props.handoffFiles}
|
||||||
file={props.file}
|
file={props.file}
|
||||||
comments={props.comments}
|
comments={props.comments}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -141,9 +141,8 @@ export function TerminalPanel(props: {
|
|||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
<Show when={props.activeTerminalDraggable()}>
|
<Show when={props.activeTerminalDraggable()}>
|
||||||
{(draggedId) => {
|
{(draggedId) => {
|
||||||
const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
|
||||||
return (
|
return (
|
||||||
<Show when={pty()}>
|
<Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}>
|
||||||
{(t) => (
|
{(t) => (
|
||||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||||
{terminalTabLabel({
|
{terminalTabLabel({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
|
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -22,7 +22,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
|||||||
import { combineCommandSections } from "@/pages/session/helpers"
|
import { combineCommandSections } from "@/pages/session/helpers"
|
||||||
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
|
||||||
|
|
||||||
export const useSessionCommands = (input: {
|
export type SessionCommandContext = {
|
||||||
command: ReturnType<typeof useCommand>
|
command: ReturnType<typeof useCommand>
|
||||||
dialog: ReturnType<typeof useDialog>
|
dialog: ReturnType<typeof useDialog>
|
||||||
file: ReturnType<typeof useFile>
|
file: ReturnType<typeof useFile>
|
||||||
@@ -49,32 +49,48 @@ export const useSessionCommands = (input: {
|
|||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
addSelectionToContext: (path: string, selection: FileSelection) => void
|
addSelectionToContext: (path: string, selection: FileSelection) => void
|
||||||
focusInput: () => void
|
focusInput: () => void
|
||||||
}) => {
|
}
|
||||||
|
|
||||||
|
const withCategory = (category: string) => {
|
||||||
|
return (option: Omit<CommandOption, "category">): CommandOption => ({
|
||||||
|
...option,
|
||||||
|
category,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionCommands = (input: SessionCommandContext) => {
|
||||||
|
const sessionCommand = withCategory(input.language.t("command.category.session"))
|
||||||
|
const fileCommand = withCategory(input.language.t("command.category.file"))
|
||||||
|
const contextCommand = withCategory(input.language.t("command.category.context"))
|
||||||
|
const viewCommand = withCategory(input.language.t("command.category.view"))
|
||||||
|
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
|
||||||
|
const modelCommand = withCategory(input.language.t("command.category.model"))
|
||||||
|
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
|
||||||
|
const agentCommand = withCategory(input.language.t("command.category.agent"))
|
||||||
|
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
|
||||||
|
|
||||||
const sessionCommands = createMemo(() => [
|
const sessionCommands = createMemo(() => [
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.new",
|
id: "session.new",
|
||||||
title: input.language.t("command.session.new"),
|
title: input.language.t("command.session.new"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
keybind: "mod+shift+s",
|
keybind: "mod+shift+s",
|
||||||
slash: "new",
|
slash: "new",
|
||||||
onSelect: () => input.navigate(`/${input.params.dir}/session`),
|
onSelect: () => input.navigate(`/${input.params.dir}/session`),
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const fileCommands = createMemo(() => [
|
const fileCommands = createMemo(() => [
|
||||||
{
|
fileCommand({
|
||||||
id: "file.open",
|
id: "file.open",
|
||||||
title: input.language.t("command.file.open"),
|
title: input.language.t("command.file.open"),
|
||||||
description: input.language.t("palette.search.placeholder"),
|
description: input.language.t("palette.search.placeholder"),
|
||||||
category: input.language.t("command.category.file"),
|
|
||||||
keybind: "mod+p",
|
keybind: "mod+p",
|
||||||
slash: "open",
|
slash: "open",
|
||||||
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
|
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
|
||||||
},
|
}),
|
||||||
{
|
fileCommand({
|
||||||
id: "tab.close",
|
id: "tab.close",
|
||||||
title: input.language.t("command.tab.close"),
|
title: input.language.t("command.tab.close"),
|
||||||
category: input.language.t("command.category.file"),
|
|
||||||
keybind: "mod+w",
|
keybind: "mod+w",
|
||||||
disabled: !input.tabs().active(),
|
disabled: !input.tabs().active(),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
@@ -82,15 +98,14 @@ export const useSessionCommands = (input: {
|
|||||||
if (!active) return
|
if (!active) return
|
||||||
input.tabs().close(active)
|
input.tabs().close(active)
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const contextCommands = createMemo(() => [
|
const contextCommands = createMemo(() => [
|
||||||
{
|
contextCommand({
|
||||||
id: "context.addSelection",
|
id: "context.addSelection",
|
||||||
title: input.language.t("command.context.addSelection"),
|
title: input.language.t("command.context.addSelection"),
|
||||||
description: input.language.t("command.context.addSelection.description"),
|
description: input.language.t("command.context.addSelection.description"),
|
||||||
category: input.language.t("command.category.context"),
|
|
||||||
keybind: "mod+shift+l",
|
keybind: "mod+shift+l",
|
||||||
disabled: !canAddSelectionContext({
|
disabled: !canAddSelectionContext({
|
||||||
active: input.tabs().active(),
|
active: input.tabs().active(),
|
||||||
@@ -103,7 +118,7 @@ export const useSessionCommands = (input: {
|
|||||||
const path = input.file.pathFromTab(active)
|
const path = input.file.pathFromTab(active)
|
||||||
if (!path) return
|
if (!path) return
|
||||||
|
|
||||||
const range = input.file.selectedLines(path)
|
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||||
if (!range) {
|
if (!range) {
|
||||||
showToast({
|
showToast({
|
||||||
title: input.language.t("toast.context.noLineSelection.title"),
|
title: input.language.t("toast.context.noLineSelection.title"),
|
||||||
@@ -114,58 +129,49 @@ export const useSessionCommands = (input: {
|
|||||||
|
|
||||||
input.addSelectionToContext(path, selectionFromLines(range))
|
input.addSelectionToContext(path, selectionFromLines(range))
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const viewCommands = createMemo(() => [
|
const viewCommands = createMemo(() => [
|
||||||
{
|
viewCommand({
|
||||||
id: "terminal.toggle",
|
id: "terminal.toggle",
|
||||||
title: input.language.t("command.terminal.toggle"),
|
title: input.language.t("command.terminal.toggle"),
|
||||||
description: "",
|
|
||||||
category: input.language.t("command.category.view"),
|
|
||||||
keybind: "ctrl+`",
|
keybind: "ctrl+`",
|
||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => input.view().terminal.toggle(),
|
onSelect: () => input.view().terminal.toggle(),
|
||||||
},
|
}),
|
||||||
{
|
viewCommand({
|
||||||
id: "review.toggle",
|
id: "review.toggle",
|
||||||
title: input.language.t("command.review.toggle"),
|
title: input.language.t("command.review.toggle"),
|
||||||
description: "",
|
|
||||||
category: input.language.t("command.category.view"),
|
|
||||||
keybind: "mod+shift+r",
|
keybind: "mod+shift+r",
|
||||||
onSelect: () => input.view().reviewPanel.toggle(),
|
onSelect: () => input.view().reviewPanel.toggle(),
|
||||||
},
|
}),
|
||||||
{
|
viewCommand({
|
||||||
id: "fileTree.toggle",
|
id: "fileTree.toggle",
|
||||||
title: input.language.t("command.fileTree.toggle"),
|
title: input.language.t("command.fileTree.toggle"),
|
||||||
description: "",
|
|
||||||
category: input.language.t("command.category.view"),
|
|
||||||
keybind: "mod+\\",
|
keybind: "mod+\\",
|
||||||
onSelect: () => input.layout.fileTree.toggle(),
|
onSelect: () => input.layout.fileTree.toggle(),
|
||||||
},
|
}),
|
||||||
{
|
viewCommand({
|
||||||
id: "input.focus",
|
id: "input.focus",
|
||||||
title: input.language.t("command.input.focus"),
|
title: input.language.t("command.input.focus"),
|
||||||
category: input.language.t("command.category.view"),
|
|
||||||
keybind: "ctrl+l",
|
keybind: "ctrl+l",
|
||||||
onSelect: () => input.focusInput(),
|
onSelect: () => input.focusInput(),
|
||||||
},
|
}),
|
||||||
{
|
terminalCommand({
|
||||||
id: "terminal.new",
|
id: "terminal.new",
|
||||||
title: input.language.t("command.terminal.new"),
|
title: input.language.t("command.terminal.new"),
|
||||||
description: input.language.t("command.terminal.new.description"),
|
description: input.language.t("command.terminal.new.description"),
|
||||||
category: input.language.t("command.category.terminal"),
|
|
||||||
keybind: "ctrl+alt+t",
|
keybind: "ctrl+alt+t",
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (input.terminal.all().length > 0) input.terminal.new()
|
if (input.terminal.all().length > 0) input.terminal.new()
|
||||||
input.view().terminal.open()
|
input.view().terminal.open()
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
{
|
viewCommand({
|
||||||
id: "steps.toggle",
|
id: "steps.toggle",
|
||||||
title: input.language.t("command.steps.toggle"),
|
title: input.language.t("command.steps.toggle"),
|
||||||
description: input.language.t("command.steps.toggle.description"),
|
description: input.language.t("command.steps.toggle.description"),
|
||||||
category: input.language.t("command.category.view"),
|
|
||||||
keybind: "mod+e",
|
keybind: "mod+e",
|
||||||
slash: "steps",
|
slash: "steps",
|
||||||
disabled: !input.params.id,
|
disabled: !input.params.id,
|
||||||
@@ -174,86 +180,78 @@ export const useSessionCommands = (input: {
|
|||||||
if (!msg) return
|
if (!msg) return
|
||||||
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
|
input.setExpanded(msg.id, (open: boolean | undefined) => !open)
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const messageCommands = createMemo(() => [
|
const messageCommands = createMemo(() => [
|
||||||
{
|
sessionCommand({
|
||||||
id: "message.previous",
|
id: "message.previous",
|
||||||
title: input.language.t("command.message.previous"),
|
title: input.language.t("command.message.previous"),
|
||||||
description: input.language.t("command.message.previous.description"),
|
description: input.language.t("command.message.previous.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
keybind: "mod+arrowup",
|
keybind: "mod+arrowup",
|
||||||
disabled: !input.params.id,
|
disabled: !input.params.id,
|
||||||
onSelect: () => input.navigateMessageByOffset(-1),
|
onSelect: () => input.navigateMessageByOffset(-1),
|
||||||
},
|
}),
|
||||||
{
|
sessionCommand({
|
||||||
id: "message.next",
|
id: "message.next",
|
||||||
title: input.language.t("command.message.next"),
|
title: input.language.t("command.message.next"),
|
||||||
description: input.language.t("command.message.next.description"),
|
description: input.language.t("command.message.next.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
keybind: "mod+arrowdown",
|
keybind: "mod+arrowdown",
|
||||||
disabled: !input.params.id,
|
disabled: !input.params.id,
|
||||||
onSelect: () => input.navigateMessageByOffset(1),
|
onSelect: () => input.navigateMessageByOffset(1),
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const agentCommands = createMemo(() => [
|
const agentCommands = createMemo(() => [
|
||||||
{
|
modelCommand({
|
||||||
id: "model.choose",
|
id: "model.choose",
|
||||||
title: input.language.t("command.model.choose"),
|
title: input.language.t("command.model.choose"),
|
||||||
description: input.language.t("command.model.choose.description"),
|
description: input.language.t("command.model.choose.description"),
|
||||||
category: input.language.t("command.category.model"),
|
|
||||||
keybind: "mod+'",
|
keybind: "mod+'",
|
||||||
slash: "model",
|
slash: "model",
|
||||||
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
|
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
|
||||||
},
|
}),
|
||||||
{
|
mcpCommand({
|
||||||
id: "mcp.toggle",
|
id: "mcp.toggle",
|
||||||
title: input.language.t("command.mcp.toggle"),
|
title: input.language.t("command.mcp.toggle"),
|
||||||
description: input.language.t("command.mcp.toggle.description"),
|
description: input.language.t("command.mcp.toggle.description"),
|
||||||
category: input.language.t("command.category.mcp"),
|
|
||||||
keybind: "mod+;",
|
keybind: "mod+;",
|
||||||
slash: "mcp",
|
slash: "mcp",
|
||||||
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
|
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
|
||||||
},
|
}),
|
||||||
{
|
agentCommand({
|
||||||
id: "agent.cycle",
|
id: "agent.cycle",
|
||||||
title: input.language.t("command.agent.cycle"),
|
title: input.language.t("command.agent.cycle"),
|
||||||
description: input.language.t("command.agent.cycle.description"),
|
description: input.language.t("command.agent.cycle.description"),
|
||||||
category: input.language.t("command.category.agent"),
|
|
||||||
keybind: "mod+.",
|
keybind: "mod+.",
|
||||||
slash: "agent",
|
slash: "agent",
|
||||||
onSelect: () => input.local.agent.move(1),
|
onSelect: () => input.local.agent.move(1),
|
||||||
},
|
}),
|
||||||
{
|
agentCommand({
|
||||||
id: "agent.cycle.reverse",
|
id: "agent.cycle.reverse",
|
||||||
title: input.language.t("command.agent.cycle.reverse"),
|
title: input.language.t("command.agent.cycle.reverse"),
|
||||||
description: input.language.t("command.agent.cycle.reverse.description"),
|
description: input.language.t("command.agent.cycle.reverse.description"),
|
||||||
category: input.language.t("command.category.agent"),
|
|
||||||
keybind: "shift+mod+.",
|
keybind: "shift+mod+.",
|
||||||
onSelect: () => input.local.agent.move(-1),
|
onSelect: () => input.local.agent.move(-1),
|
||||||
},
|
}),
|
||||||
{
|
modelCommand({
|
||||||
id: "model.variant.cycle",
|
id: "model.variant.cycle",
|
||||||
title: input.language.t("command.model.variant.cycle"),
|
title: input.language.t("command.model.variant.cycle"),
|
||||||
description: input.language.t("command.model.variant.cycle.description"),
|
description: input.language.t("command.model.variant.cycle.description"),
|
||||||
category: input.language.t("command.category.model"),
|
|
||||||
keybind: "shift+mod+d",
|
keybind: "shift+mod+d",
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
input.local.model.variant.cycle()
|
input.local.model.variant.cycle()
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const permissionCommands = createMemo(() => [
|
const permissionCommands = createMemo(() => [
|
||||||
{
|
permissionsCommand({
|
||||||
id: "permissions.autoaccept",
|
id: "permissions.autoaccept",
|
||||||
title:
|
title:
|
||||||
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
|
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
|
||||||
? input.language.t("command.permissions.autoaccept.disable")
|
? input.language.t("command.permissions.autoaccept.disable")
|
||||||
: input.language.t("command.permissions.autoaccept.enable"),
|
: input.language.t("command.permissions.autoaccept.enable"),
|
||||||
category: input.language.t("command.category.permissions"),
|
|
||||||
keybind: "mod+shift+a",
|
keybind: "mod+shift+a",
|
||||||
disabled: !input.params.id || !input.permission.permissionsEnabled(),
|
disabled: !input.params.id || !input.permission.permissionsEnabled(),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
@@ -269,15 +267,14 @@ export const useSessionCommands = (input: {
|
|||||||
: input.language.t("toast.permissions.autoaccept.off.description"),
|
: input.language.t("toast.permissions.autoaccept.off.description"),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const sessionActionCommands = createMemo(() => [
|
const sessionActionCommands = createMemo(() => [
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.undo",
|
id: "session.undo",
|
||||||
title: input.language.t("command.session.undo"),
|
title: input.language.t("command.session.undo"),
|
||||||
description: input.language.t("command.session.undo.description"),
|
description: input.language.t("command.session.undo.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
slash: "undo",
|
slash: "undo",
|
||||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -298,12 +295,11 @@ export const useSessionCommands = (input: {
|
|||||||
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
|
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
|
||||||
input.setActiveMessage(priorMessage)
|
input.setActiveMessage(priorMessage)
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.redo",
|
id: "session.redo",
|
||||||
title: input.language.t("command.session.redo"),
|
title: input.language.t("command.session.redo"),
|
||||||
description: input.language.t("command.session.redo.description"),
|
description: input.language.t("command.session.redo.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
slash: "redo",
|
slash: "redo",
|
||||||
disabled: !input.params.id || !input.info()?.revert?.messageID,
|
disabled: !input.params.id || !input.info()?.revert?.messageID,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -323,12 +319,11 @@ export const useSessionCommands = (input: {
|
|||||||
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
|
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
|
||||||
input.setActiveMessage(priorMsg)
|
input.setActiveMessage(priorMsg)
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.compact",
|
id: "session.compact",
|
||||||
title: input.language.t("command.session.compact"),
|
title: input.language.t("command.session.compact"),
|
||||||
description: input.language.t("command.session.compact.description"),
|
description: input.language.t("command.session.compact.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
slash: "compact",
|
slash: "compact",
|
||||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -348,22 +343,21 @@ export const useSessionCommands = (input: {
|
|||||||
providerID: model.provider.id,
|
providerID: model.provider.id,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.fork",
|
id: "session.fork",
|
||||||
title: input.language.t("command.session.fork"),
|
title: input.language.t("command.session.fork"),
|
||||||
description: input.language.t("command.session.fork.description"),
|
description: input.language.t("command.session.fork.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
slash: "fork",
|
slash: "fork",
|
||||||
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
disabled: !input.params.id || input.visibleUserMessages().length === 0,
|
||||||
onSelect: () => input.dialog.show(() => <DialogFork />),
|
onSelect: () => input.dialog.show(() => <DialogFork />),
|
||||||
},
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const shareCommands = createMemo(() => {
|
const shareCommands = createMemo(() => {
|
||||||
if (input.sync.data.config.share === "disabled") return []
|
if (input.sync.data.config.share === "disabled") return []
|
||||||
return [
|
return [
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.share",
|
id: "session.share",
|
||||||
title: input.info()?.share?.url
|
title: input.info()?.share?.url
|
||||||
? input.language.t("session.share.copy.copyLink")
|
? input.language.t("session.share.copy.copyLink")
|
||||||
@@ -371,7 +365,6 @@ export const useSessionCommands = (input: {
|
|||||||
description: input.info()?.share?.url
|
description: input.info()?.share?.url
|
||||||
? input.language.t("toast.session.share.success.description")
|
? input.language.t("toast.session.share.success.description")
|
||||||
: input.language.t("command.session.share.description"),
|
: input.language.t("command.session.share.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
slash: "share",
|
slash: "share",
|
||||||
disabled: !input.params.id,
|
disabled: !input.params.id,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -441,12 +434,11 @@ export const useSessionCommands = (input: {
|
|||||||
|
|
||||||
await copy(url, false)
|
await copy(url, false)
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
{
|
sessionCommand({
|
||||||
id: "session.unshare",
|
id: "session.unshare",
|
||||||
title: input.language.t("command.session.unshare"),
|
title: input.language.t("command.session.unshare"),
|
||||||
description: input.language.t("command.session.unshare.description"),
|
description: input.language.t("command.session.unshare.description"),
|
||||||
category: input.language.t("command.category.session"),
|
|
||||||
slash: "unshare",
|
slash: "unshare",
|
||||||
disabled: !input.params.id || !input.info()?.share?.url,
|
disabled: !input.params.id || !input.info()?.share?.url,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -468,7 +460,7 @@ export const useSessionCommands = (input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,49 @@
|
|||||||
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
||||||
import { JSXElement } from "solid-js"
|
|
||||||
import type { Transformer } from "@thisbeyond/solid-dnd"
|
import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||||
|
import { createRoot, onCleanup, type JSXElement } from "solid-js"
|
||||||
|
|
||||||
|
type DragEvent = { draggable?: { id?: unknown } }
|
||||||
|
|
||||||
|
const isDragEvent = (event: unknown): event is DragEvent => {
|
||||||
|
if (typeof event !== "object" || event === null) return false
|
||||||
|
return "draggable" in event
|
||||||
|
}
|
||||||
|
|
||||||
export const getDraggableId = (event: unknown): string | undefined => {
|
export const getDraggableId = (event: unknown): string | undefined => {
|
||||||
if (typeof event !== "object" || event === null) return undefined
|
if (!isDragEvent(event)) return undefined
|
||||||
if (!("draggable" in event)) return undefined
|
const draggable = event.draggable
|
||||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
|
||||||
if (!draggable) return undefined
|
if (!draggable) return undefined
|
||||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConstrainDragXAxis = (): JSXElement => {
|
const createTransformer = (id: string, axis: "x" | "y"): Transformer => ({
|
||||||
|
id,
|
||||||
|
order: 100,
|
||||||
|
callback: (transform) => (axis === "x" ? { ...transform, x: 0 } : { ...transform, y: 0 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSXElement => {
|
||||||
const context = useDragDropContext()
|
const context = useDragDropContext()
|
||||||
if (!context) return <></>
|
if (!context) return null
|
||||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||||
const transformer: Transformer = {
|
const transformer = createTransformer(transformerId, axis)
|
||||||
id: "constrain-x-axis",
|
const dispose = createRoot((dispose) => {
|
||||||
order: 100,
|
onDragStart((event) => {
|
||||||
callback: (transform) => ({ ...transform, x: 0 }),
|
const id = getDraggableId(event)
|
||||||
}
|
if (!id) return
|
||||||
onDragStart((event) => {
|
addTransformer("draggables", id, transformer)
|
||||||
const id = getDraggableId(event)
|
})
|
||||||
if (!id) return
|
onDragEnd((event) => {
|
||||||
addTransformer("draggables", id, transformer)
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
removeTransformer("draggables", id, transformer.id)
|
||||||
|
})
|
||||||
|
return dispose
|
||||||
})
|
})
|
||||||
onDragEnd((event) => {
|
onCleanup(dispose)
|
||||||
const id = getDraggableId(event)
|
return null
|
||||||
if (!id) return
|
|
||||||
removeTransformer("draggables", id, transformer.id)
|
|
||||||
})
|
|
||||||
return <></>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConstrainDragYAxis = (): JSXElement => {
|
export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis")
|
||||||
const context = useDragDropContext()
|
|
||||||
if (!context) return <></>
|
export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis")
|
||||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
|
||||||
const transformer: Transformer = {
|
|
||||||
id: "constrain-y-axis",
|
|
||||||
order: 100,
|
|
||||||
callback: (transform) => ({ ...transform, y: 0 }),
|
|
||||||
}
|
|
||||||
onDragStart((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
addTransformer("draggables", id, transformer)
|
|
||||||
})
|
|
||||||
onDragEnd((event) => {
|
|
||||||
const id = getDraggableId(event)
|
|
||||||
if (!id) return
|
|
||||||
removeTransformer("draggables", id, transformer.id)
|
|
||||||
})
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user