Compare commits

..
255 changed files with 1414 additions and 11697 deletions

No files matched your search

-12
View File
@@ -506,10 +506,6 @@
"name": "@opencode/gui-extensions",
"version": "2.0.24",
"dependencies": {
"@betteroffice/docx": "0.4.3",
"@betteroffice/fonts": "0.4.0",
"@betteroffice/pptx": "0.2.0",
"@betteroffice/xlsx": "0.3.0",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/solid": "0.5.0",
@@ -1638,14 +1634,6 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@betteroffice/docx": ["@betteroffice/docx@0.4.3", "", { "peerDependencies": { "@betteroffice/fonts": "^0" }, "optionalPeers": ["@betteroffice/fonts"] }, "sha512-CEErXJH3iCsi2a61gTNkAofLCzSTcktLmp710gGhMwbBfOqwQ2yI7dRFBV3ElGa9JxkS03IfejvLX0sAvlTrUQ=="],
"@betteroffice/fonts": ["@betteroffice/fonts@0.4.0", "", { "peerDependencies": { "@betteroffice/fonts-cjk": "^0" }, "optionalPeers": ["@betteroffice/fonts-cjk"] }, "sha512-x1BVwYDAgY5R6TWI4t9NLguSiAEbH5GItN2ZlpBIFoRXTIJgjBsXd6sUxPGpexXOyY6KdhRjG8XvHjyAYxEGCw=="],
"@betteroffice/pptx": ["@betteroffice/pptx@0.2.0", "", {}, "sha512-pNPQ58tlA+pVibERbLdQNskvP7a0GIMf4jhkCU/gPp8043Ichf6gB3PSb4ra9sfqoIaWqOaZKCWyptovA2+Q0A=="],
"@betteroffice/xlsx": ["@betteroffice/xlsx@0.3.0", "", {}, "sha512-K2bHtdk/epAxsmvxJlIWBRndjP3ltQU0Oh3p4gcZd2n5/YgXglo3SmMYX4nQe8WEm/yvz91p+aIasKyXXQP9Wg=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-iiIwlNoycOMUiaUzL1ZirLapCG3WY8cg9hSj4KgE/JYIWgqauoHikIlfUt7SdMqqGIkb4iZu6I1MxgDDJaRfBA=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-vzF5I8/QHutYM6j0R73ghqBCRntL6/fegzivyDTO9Ro=",
"aarch64-linux": "sha256-foy1hI+cewpLJvNqTZCCNTqV4tnQSDK55ZRs87MvnI8=",
"aarch64-darwin": "sha256-wl6+Omz25VxLRMsh0rvFZCqBErk0o3mh9cjuUYsYAP4="
"x86_64-linux": "sha256-mrsVbX15SgLsQWBknYKmHSBpWhE6BnyUrYzxIWL35js=",
"aarch64-linux": "sha256-uskMkbG9jeGoal1mHV4rpkkvBb3TDBvQcDdQT/bP9y4=",
"aarch64-darwin": "sha256-/8nVBK6lBRO34Id/jKOUeEaEHHYJhh8cFESWfhzzln0="
}
}
@@ -507,7 +507,6 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
if (part.text.length === 0 && !encryptedContent) return undefined
return {
type: "reasoning",
...(id === undefined ? {} : { id }),
@@ -4011,46 +4011,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops empty reasoning items without summary or encrypted content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_reasoning_empty_shell",
model,
messages: [
Message.user("What changed?"),
Message.assistant([
{
type: "reasoning",
text: "",
providerMetadata: {
openai: {
itemId: "rs_6aa28a10c05cf9f566f44022:rs_01a08aeb51217b92a5b853a0cb5b20ca",
reasoningEncryptedContent: null,
},
},
},
{ type: "text", text: "The parser changed." },
]),
Message.user("Summarize it."),
],
providerOptions: { store: false },
}),
)
expect(prepared.body.input).toEqual([
{ type: "message", role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "message",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "The parser changed." }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
])
}),
)
it.effect("assembles streamed function call input", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -282,28 +282,13 @@ story("restores the current URL each time the same submitted navigation is block
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByRole("alert")).toHaveText("Only web pages can open here.")
await expect(root.getByRole("alert")).toHaveText("Request failed")
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
}
})
story("explains why a typed address cannot open instead of searching for it", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
for (const [typed, reason] of [
["ftp://example.com", "Only web pages can open here."],
["https://user:pass@example.com", "Addresses with a user name or password can't open here."],
]) {
await address.fill(typed)
await address.press("Enter")
await expect(root.getByRole("alert")).toHaveText(reason)
await expect(address).toHaveValue("https://alpha.example/")
}
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
@@ -450,9 +435,6 @@ story("suggests visited pages and opens the first one that completes the typed a
const options = page.getByRole("option")
await expect(options).toHaveCount(1)
await expect(options.first()).toHaveAttribute("aria-selected", "true")
// Arrow keys that pick IME candidates leave the suggestions alone.
await address.dispatchEvent("keydown", { key: "ArrowDown", isComposing: true })
await expect(options.first()).toHaveAttribute("aria-selected", "true")
await address.press("Enter")
await expect(address).toHaveValue("http://localhost:5173/settings")
@@ -469,39 +451,14 @@ story("suggests visited pages and opens the first one that completes the typed a
story("lists recent pages on a blank tab and opens one", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await root.getByRole("button", { name: "Seed history", exact: true }).click()
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Recent", { exact: true })).toBeVisible()
// The globe at the field's start belongs to the field: a click there focuses it.
await expect(address).not.toBeFocused()
await root.locator('[data-component="browser-address"]').click({ position: { x: 16, y: 14 } })
await expect(address).toBeFocused()
await address.blur()
await root.getByRole("button", { name: /^Settings - Preview/ }).click()
await expect(address).toHaveValue("http://localhost:5173/settings")
await expect(root.getByRole("combobox", { name: "Browser address", exact: true })).toHaveValue(
"http://localhost:5173/settings",
)
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
// The opened page owns the field afterwards: an edit that is never submitted shows its URL again.
await address.click()
await address.fill("unsubmitted")
await address.blur()
await expect(address).toHaveValue("http://localhost:5173/settings")
})
story("adds the whole page to the session and keeps the draft when the button is pressed again", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const field = root.locator('[data-component="browser-address"]')
const add = root.getByRole("button", { name: "Add page to session", exact: true })
await field.hover()
await add.click()
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
await editor.fill("Check the header layout")
await field.hover()
await add.click()
await expect(editor).toHaveValue("Check the header layout")
await editor.press("Enter")
await expect(root.getByTestId("fixture-comments").getByRole("listitem")).toHaveText([/: Check the header layout$/])
})
story("focuses the address field when the page uses the address shortcut", async ({ page }) => {
@@ -521,22 +478,12 @@ story("shows the page's cookies in the site information and clears them", async
await expect(site).toContainText("0 cookies in use")
})
story("zooms the page from the browser options in view and keeps its zoom in the address field", async ({ page }) => {
story("zooms the page from the browser options and shows its zoom", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const options = root.getByRole("button", { name: "Browser options", exact: true })
await options.click()
await root.getByRole("button", { name: "Browser options", exact: true }).click()
await page.getByRole("menuitem", { name: /^Zoom in/ }).click()
await expect(root.getByText("Zoom: 110", { exact: true })).toBeVisible()
// The menu covered the page with a still; it closes so the zoomed page shows at once.
await expect(page.getByRole("menu")).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
const zoom = root.getByRole("button", { name: "110%", exact: true })
await expect(zoom).toBeVisible()
await options.click()
await expect(page.getByRole("menuitem", { name: /^Reset zoom/ })).toContainText("110%")
await page.keyboard.press("Escape")
await zoom.click()
await page.getByRole("menuitem", { name: /^Reset zoom/ }).click()
await expect(root.getByText("Zoom: 100", { exact: true })).toBeVisible()
await expect(zoom).toBeHidden()
})
@@ -85,7 +85,7 @@ const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
const servers: Servers = { list: () => [], get: () => undefined }
const servers: Servers = { list: () => [] }
const workspaces: Workspaces = { on: () => () => undefined }
@@ -348,7 +348,6 @@ function standIn(overrides: Partial<Interface>): Interface {
keybind: () => [],
matches: () => false,
servers: () => ["local"],
server: () => undefined,
...overrides,
}
}
+1 -81
View File
@@ -1,8 +1,6 @@
import { expect, test, type Page } from "@playwright/test"
import { holdRoute, NO_PROVIDER, project, REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { holdRoute, seed, sessionHref } from "../utils/app"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { mockRemoteServer } from "../utils/workspace"
import { expectAppVisible } from "../utils/waits"
test.use({ serviceWorkers: "block" })
@@ -87,11 +85,8 @@ test("Home shows loaded sessions before the location request resolves", async ({
test("Home and the directory picker load without newer browser APIs", async ({ page }) => {
await page.addInitScript(() => {
// Safari 16.6 has none of these APIs. Remove them before the web entry runs.
// SAFETY: `Partial` only makes the static method optional so `delete` type-checks; the target is the real global.
delete (Map as Partial<typeof Map>).groupBy
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).withResolvers
// SAFETY: as above, for the real global `Promise`.
delete (Promise as Partial<typeof Promise>).try
})
await openHome(page, { fileList: () => [] })
@@ -106,81 +101,6 @@ test("Home and the directory picker load without newer browser APIs", async ({ p
await expect(target).toBeVisible()
})
test("adding a project to a signed-out server re-pairs it, then continues at the paired address", async ({ page }) => {
// The saved address rejects the new token, so saving moves the server to the link's address.
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { name: "Remote", password: "old-password" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()
await remote.getByRole("button", { name: "Add project", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(`${paired}/auth/connect/one-time-code`)
await editor.getByRole("button", { name: "Save", exact: true }).click()
// The folder picker reads the paired address; the removed one would reject the request.
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})
test("a server whose saved password changes works with the new password", async ({ page }) => {
const accepted = { password: "old-password" }
await mockOpenCodeServer(page, {
server: REMOTE_SERVER,
directory: "/remote/project",
project: project({ id: "proj_remote", directory: "/remote/project" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: () => [],
password: () => accepted.password,
})
// Saved with the password the server accepts until the test changes it.
await seed(page, {
storage: {
"opencode.global.dat:server": {
list: [{ type: "http", displayName: "Remote", http: { url: REMOTE_SERVER, password: "old-password" } }],
},
},
})
await openHome(page, { fileList: () => [] })
const remote = page.locator("[data-home-row]").filter({ hasText: "Remote" })
const picker = page.getByRole("dialog").filter({ has: page.getByRole("button", { name: "Select folder" }) })
// Use the server once, so its controller exists with the old password.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await picker.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(picker).toHaveCount(0)
await remote.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Edit", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Edit server" })
await editor.getByPlaceholder("password").fill("new-password")
// The server now rejects the old password, as after `opencode service set password`.
accepted.password = "new-password"
await editor.getByRole("button", { name: "Save", exact: true }).click()
await expect(editor).toHaveCount(0)
// The picker reads the folder through the server's controller, which must use the saved password now.
await remote.getByRole("button", { name: "Add project", exact: true }).click()
await expect(picker.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
await expect(picker.getByText("Unable to read this folder")).toHaveCount(0)
})
const recovery = "C:/OpenCode/Worktrees/project-menu-recovery"
const worktree =
+3 -83
View File
@@ -1,7 +1,6 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
const directory = "C:/Projects/settings-demo"
@@ -464,7 +463,7 @@ test("worktrees follow inventory events, wait for session counts, and delete by
worktrees: () => inventory,
onWorktreeRemove: (body) => {
inventory.splice(
inventory.findIndex((item) => item.directory === body.directory),
inventory.findIndex((item) => item.directory === (body as { directory: string }).directory),
1,
)
},
@@ -500,7 +499,6 @@ test("worktrees follow inventory events, wait for session counts, and delete by
// Home never listed this session, so only the new worktree's directory read can show it.
view.sessions.push(session({ id: "ses_discovered", directory: discovered, title: "Discovered session", projectID }))
inventory.push({ directory: discovered, strategy: "git" })
// SAFETY: a worktree.updated event carries only the project ID, which is all the app reads from it.
await view.push([
{ id: "evt_settings_worktree_updated", created: Date.now(), type: "worktree.updated", data: { projectID } },
] as OpenCodeEvent[])
@@ -584,9 +582,7 @@ test.describe("worktrees prefetch", () => {
test("server Worktrees hover only prefetches metadata", async ({ page }) => {
const { settings } = await open(page)
const calls = { projects: 0, worktrees: new Array<string>(), refreshes: new Array<string>() }
const calls = { projects: 0, worktrees: [] as string[], refreshes: [] as string[] }
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/worktree/refresh")
calls.refreshes.push(request.postDataJSON().projectID)
@@ -758,9 +754,7 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await expect(editor.getByLabel("Pairing link", { exact: true })).toBeFocused()
await expect(editor.getByPlaceholder("password")).toHaveCount(0)
await editor.getByRole("button", { name: "Use password", exact: true }).click()
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
const name = editor.getByPlaceholder("Localhost", { exact: true })
await name.click()
@@ -772,80 +766,6 @@ test("the add server dialog keeps focus above fullscreen settings", async ({ pag
await expect(settings).toBeVisible()
})
test("the add server dialog pairs from a one-time link and explains a spent one", async ({ page }) => {
const paired = "http://127.0.0.1:4098"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
await mockOpenCodeServer(page, {
server: paired,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing: { code: "one-time-code", token: "session-token" },
})
const { settings } = await open(page)
const link = `${paired}/auth/connect/one-time-code`
const addServer = async () => {
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor.getByLabel("Pairing link", { exact: true }).fill(link)
await editor.getByRole("button", { name: "Add server", exact: true }).click()
return editor
}
const added = await addServer()
await expect(added).toBeHidden()
await expect(settings.getByText(paired, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to settings" }).click()
const spent = await addServer()
await expect(spent.getByRole("alert")).toHaveText(
"This pairing link expired or was already used. Run opencode pair to get a new one.",
)
})
test("a pairing code with several addresses keeps the first one that works with its token", async ({ page }) => {
// Both addresses reach one server. The second spends the code first, so the first answers 401, and the token works on
// it as on every address of that server.
const first = "http://127.0.0.1:4098"
const second = "http://127.0.0.1:4099"
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
for (const [server, pairing] of [
[first, { code: "spent-by-the-other-address", token: "session-token" }],
[second, { code: "one-time-code", token: "session-token" }],
] as const) {
await mockOpenCodeServer(page, {
server,
directory: "/remote/paired",
project: project({ id: "proj_paired", directory: "/remote/paired" }),
provider: NO_PROVIDER,
sessions: [],
pageMessages: () => ({ items: [] }),
password: "session-token",
pairing,
})
}
const { settings } = await open(page)
await settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }).hover()
await settings.getByRole("button", { name: "Add server" }).click()
const editor = page.getByRole("dialog", { name: "Add server" })
await editor
.getByLabel("Pairing link", { exact: true })
.fill(JSON.stringify({ code: "one-time-code", urls: [first, second] }))
await editor.getByRole("button", { name: "Add server", exact: true }).click()
await expect(editor).toBeHidden()
await expect(settings.getByText(first, { exact: true })).toBeVisible()
await expect(settings.getByText(second, { exact: true })).toHaveCount(0)
})
test("the tab layout preference switches to vertical tabs and survives reload", async ({ page }) => {
const { settings } = await open(page)
const layout = settings.locator('[data-action="settings-tab-layout"]')
+166 -32
View File
@@ -56,6 +56,71 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
await expectPath(page, sessionHref(b.id))
})
test("inactive session attention dots share the close control without marking the avatar or active tab", async ({
page,
}) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b],
sessionPermissions: {
[b.id]: [
{ id: "permission-tab-b", sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
const dot = tabB.locator('[data-slot="tab-unread-dot"]')
const close = tabB.getByRole("button", { name: "Close tab", exact: true })
await expect(tabA.locator('[data-slot="tab-title"]')).toHaveText(a.title)
await expect(tabB.locator('[data-slot="tab-title"]')).toHaveText(b.title)
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(close).toHaveCSS("opacity", "0")
await expect(tabB.locator('[data-slot="project-avatar-unread-dot"]')).toHaveCount(0)
await expect
.poll(() =>
dot.evaluate((element) => {
const dot = element.getBoundingClientRect()
const slot = element.parentElement!.getBoundingClientRect()
return (
Math.abs(dot.x + dot.width / 2 - slot.x - slot.width / 2) +
Math.abs(dot.y + dot.height / 2 - slot.y - slot.height / 2)
)
}),
)
.toBeLessThan(1)
await tabB.hover()
await expect(dot).toBeHidden()
await expect(close).toHaveCSS("opacity", "1")
await page.mouse.move(0, 0)
await tabB.locator("[data-titlebar-tab-link]").focus()
await page.keyboard.press("Tab")
await expect(close).toBeFocused()
await expect(dot).toBeHidden()
await expect(close).toHaveCSS("opacity", "1")
await close.blur()
await expect(dot).toBeVisible()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB).toHaveAttribute("data-active", "true")
await expect(dot).toHaveCount(0)
await tabA.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await tabB.hover()
await close.click()
await expect(tabB).toHaveCount(0)
await expectPath(page, sessionHref(a.id))
})
test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
await page.goto(sessionHref(a.id))
@@ -98,43 +163,112 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
})
for (const row of [
{ tabLayout: "horizontal", width: 360 },
{ tabLayout: "vertical", width: 390 },
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
]) {
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
test.describe(`mobile drawer (${row.tabLayout})`, () => {
test.use({ hasTouch: row.hasTouch })
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[b.id]: [
{
id: "permission-mobile-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
})
await page.goto(sessionHref(a.id))
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("width", "20px")
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("height", "20px")
await expect
.poll(() =>
tabB.evaluate((element) => {
const dot = element.querySelector('[data-slot="tab-unread-slot"]')!.getBoundingClientRect()
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
return dot.right <= close.left || dot.left >= close.right
}),
)
.toBe(true)
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(tabB.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
await page.goto(sessionHref(a.id))
await page.getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
}
test("icon-only inactive tabs retain their avatar and attention badge instead of showing a close overlay", async ({
page,
}) => {
await mockWorkspace(page, {
name: "CompactTabs",
sessions: [
a,
b,
...Array.from({ length: 30 }, (_, index) => ({ id: `ses_compact_${index}`, title: `Compact ${index}` })),
],
sessionPermissions: {
[b.id]: [
{
id: "permission-compact-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
})
await page.goto(sessionHref(a.id))
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden()
await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px")
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px")
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.hover()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCount(0)
})
test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 480 })
const directory = "C:/OpenCode/Tabs"
+18 -26
View File
@@ -1,14 +1,13 @@
import { expect, test, type Page } from "@playwright/test"
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { createServer, type ServerResponse } from "node:http"
import type { AddressInfo } from "node:net"
import { once } from "node:events"
import { createHash } from "node:crypto"
import { join, extname, relative, sep } from "node:path"
import { tmpdir } from "node:os"
import { fileURLToPath } from "node:url"
import { build } from "vite"
import { onDemandAsset, serviceWorker } from "../../vite.pwa"
import { serviceWorker } from "../../vite.pwa"
type Site = {
url: string
@@ -128,14 +127,14 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
const file = builds[state.version][path]
const types = new Map([
[".js", "text/javascript"],
[".html", "text/html"],
[".json", "application/json"],
[".wasm", "application/wasm"],
])
const types: Record<string, string> = {
".js": "text/javascript",
".html": "text/html",
".json": "application/json",
".wasm": "application/wasm",
}
response.setHeader("content-type", types.get(extname(path)) ?? "application/octet-stream")
response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream")
if (file) return void response.end(file)
@@ -146,8 +145,9 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
server.listen(0, "127.0.0.1")
await once(server, "listening")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
try {
await use({
@@ -284,11 +284,9 @@ fixture(
await expect
.poll(() =>
replacement.evaluate(() => {
// This runs in the service worker, whose global scope holds its registration.
const registration =
"registration" in self && self.registration instanceof ServiceWorkerRegistration ? self.registration : undefined
const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration
return { waiting: !!registration?.waiting, active: registration?.active?.state }
return { waiting: !!registration.waiting, active: registration.active?.state }
}),
)
.toEqual({ waiting: false, active: "activated" })
@@ -380,22 +378,15 @@ fixture("does not substitute cached HTML for API or missing asset navigations",
expect(await asset?.text()).toBe("Not found")
})
test("the production build precaches every deployable file except on-demand Office assets", async ({
page,
context,
}) => {
test("the production build precaches every deployable file", async ({ page, context }) => {
const directory = new URL("../../dist/", import.meta.url)
const deployable = (await readdir(directory, { recursive: true, withFileTypes: true }))
const files = (await readdir(directory, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/"))
.filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path))
const files = deployable.filter((path) => !onDemandAsset(path))
expect(files.length).toBeGreaterThan(1)
// The Office engines ship but load on demand, so a build without them would make this exclusion vacuous.
expect(deployable.some((path) => path.endsWith(".wasm") && onDemandAsset(path))).toBe(true)
const server = createServer(async (request, response) => {
const path = new URL(request.url ?? "/", "http://localhost").pathname
@@ -420,8 +411,9 @@ test("the production build precaches every deployable file except on-demand Offi
server.listen(0, "127.0.0.1")
await once(server, "listening")
// SAFETY: a server listening on a host and port reports an AddressInfo; only pipe servers report a string.
const address = server.address() as AddressInfo
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
const url = `http://127.0.0.1:${address.port}`
try {
-25
View File
@@ -135,8 +135,6 @@ export interface MockServerConfig {
strictDirectory?: boolean
// Answers 401 UnauthorizedError unless a request carries this password; a function may change it mid-test.
password?: Resolvable<string>
// Serves GET /auth/connect/:code: `code` redeems once for `token` (send it as the password); others answer 401.
pairing?: { code: string; token: string }
}
export type MockPtyInfo = {
@@ -403,29 +401,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
})
if (config.pairing) {
const pairing = { ...config.pairing, redeemed: false }
await page.route(`${server}/auth/connect/*`, (route) => {
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers: corsHeaders })
const code = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1) ?? "")
if (code !== pairing.code || pairing.redeemed) {
return route.fulfill({
status: 401,
headers: corsHeaders,
json: Schema.encodeSync(MockUnauthorized)(
new MockUnauthorized({ message: "Pairing link expired or already used" }),
),
})
}
pairing.redeemed = true
return route.fulfill({ headers: corsHeaders, json: { token: pairing.token } })
})
}
if (config.pty) {
const host = new URL(server).host
await page.routeWebSocket(
+10 -23
View File
@@ -31,6 +31,7 @@ export function createHomeProjectsController(home: HomeController) {
const settings = useSettingsSurface()
const serverManagement = useServerActionsController()
const global = useGlobal()
const authenticate = ServerConnection.authenticate
const revealProject = useRevealProject()
const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} })
@@ -40,23 +41,6 @@ export function createHomeProjectsController(home: HomeController) {
{ initialValue: _state },
)
function edit(conn: ServerConnection.Http, onSave?: (saved: ServerConnection.Http) => void) {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} onSave={(saved) => onSave?.(saved)} />)
})
}
// An expired pairing session or a changed password signs the app out of an HTTP server; signing in again edits it,
// and the action that asked for sign-in continues once the dialog saves, as it does for extension servers. It continues
// with the saved connection: the one it started with still carries the rejected credentials.
function authenticate(conn: ServerConnection.Any, onConnected?: (conn: ServerConnection.Any) => void) {
if (conn.type !== "http" || !home.server.health(conn)?.unauthorized)
return ServerConnection.authenticate(conn, () => onConnected?.(conn))
edit(conn, onConnected)
return true
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
@@ -94,10 +78,14 @@ export function createHomeProjectsController(home: HomeController) {
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
canHide: (conn: ServerConnection.Any) => serverManagement.connection.canHide(ServerConnection.key(conn)),
hide: (conn: ServerConnection.Any) => serverManagement.connection.setHidden(ServerConnection.key(conn), true),
edit: (conn: ServerConnection.Http) => edit(conn),
edit: (conn: ServerConnection.Http) => {
void import("@/servers/connect/dialog").then(({ DialogServer }) => {
void dialog.show(() => <DialogServer mode="edit" server={conn} />)
})
},
authenticate: (conn: ServerConnection.Any) => authenticate(conn),
focus: (conn: ServerConnection.Any) => {
if (authenticate(conn, (next) => home.selection.focusServer(next))) return
if (authenticate(conn, () => home.selection.focusServer(conn))) return
home.selection.focusServer(conn)
},
},
@@ -106,12 +94,12 @@ export function createHomeProjectsController(home: HomeController) {
recentlyClosed: home.project.recentlyClosed,
homedir: home.project.homedir,
select: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, (next) => home.project.select(next, directory))) return
if (authenticate(conn, () => home.project.select(conn, directory))) return
home.project.select(conn, directory)
},
add: home.project.add,
openNewSession: (conn: ServerConnection.Any, directory: string) => {
if (authenticate(conn, (next) => home.project.openProjectNewSession(next, directory))) return
if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return
home.project.openProjectNewSession(conn, directory)
},
canImportSession: !!platform.openAttachmentPickerDialog,
@@ -131,7 +119,6 @@ export function createHomeProjectsController(home: HomeController) {
const api = home.server.context(conn).sdk.api.session
// SAFETY: the payload is SessionTransfer.Data's own encoding, which the import endpoint decodes and validates.
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
@@ -165,7 +152,7 @@ export function createHomeProjectsController(home: HomeController) {
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, (next) => choose(next))) return
if (authenticate(conn, () => choose(conn))) return
if (home.server.health(conn)?.healthy === false) return
choose(conn)
+4 -11
View File
@@ -33,11 +33,6 @@ const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whi
const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}`
// Extension servers ask for sign-in themselves; an HTTP server that rejects its saved credentials needs a new
// pairing link or password.
const signInRequired = (server: ServerConnection.Any, health: ServerHealth | undefined) =>
(server.type === "extension" && server.authenticationRequired) || (server.type === "http" && !!health?.unauthorized)
const projectContextMenuID = (server: ServerConnection.Any, directory: string) =>
`project:${ServerConnection.key(server)}:${directory}`
@@ -163,7 +158,7 @@ export function HomeProjectsView(props: HomeProjectsViewProps) {
}
function HomeProjectsPanel(props: HomeProjectsViewProps) {
const [contextMenu, setContextMenu] = createStore<{ open: string | undefined }>({ open: undefined })
const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined })
const contextMenuProps = {
contextMenuOpen: (id: string) => contextMenu.open === id,
@@ -221,9 +216,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
when={
props.servers.length > 1 ||
props.servers.some(
(server) =>
signInRequired(server, props.serverHealth(server)) ||
(server.type === "extension" && server.connecting),
(server) => server.type === "extension" && (server.authenticationRequired || server.connecting),
)
}
fallback={
@@ -260,7 +253,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
const authentication = () => signInRequired(item, props.serverHealth(item))
const authentication = () => item.type === "extension" && item.authenticationRequired
const connecting = () => item.type === "extension" && item.connecting
return (
@@ -365,7 +358,7 @@ function HomeServerRow(props: {
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const authentication = () => signInRequired(props.server, props.health)
const authentication = () => props.server.type === "extension" && props.server.authenticationRequired
const incompatible = () => !!props.health?.incompatible
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const contextMenuID = () => serverContextMenuID(props.server)
@@ -7,23 +7,14 @@ import type {
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { formatServerError } from "@/runtime/server/errors"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" | "external" }>
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
type Polling = {
generation: number
timer?: ReturnType<typeof setTimeout>
disposed: boolean
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt?: Authorization
}
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
// Console sign-in is the connection method for both providers.
export const CONSOLE_INTEGRATION = "opencode"
@@ -44,7 +35,7 @@ export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
if (actual === undefined) return false
const equal = Array.isArray(actual)
? actual.some((item) => item === condition.value)
? typeof condition.value === "string" && actual.includes(condition.value)
: actual === condition.value
return condition.op === "eq" ? equal : !equal
@@ -52,7 +43,7 @@ export function providerFormDefaults(fields: ProviderConnectMethod["form"]) {
if (!active) return answer
return Object.assign(answer, { [field.key]: field.default })
return { ...answer, [field.key]: field.default }
}, {})
}
@@ -83,8 +74,9 @@ export function createProviderConnectionController(options: {
// Not createResource: the dialog is owned by whichever page opened it, so reading a pending
// resource here would suspend that page's <Suspense> and blank the screen behind the dialog.
const [integration, setIntegration] = createStore<{ loading: boolean; latest?: IntegrationInfo }>({
const [integration, setIntegration] = createStore({
loading: true,
latest: undefined as IntegrationInfo | undefined,
})
createEffect(
@@ -108,8 +100,7 @@ export function createProviderConnectionController(options: {
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod =>
method.type === "key" || method.type === "oauth" || method.type === "external",
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
)
if (values?.length) return [...values]
@@ -117,19 +108,14 @@ export function createProviderConnectionController(options: {
return [{ type: "key", label: language.t("provider.connect.method.apiKey") }]
})
const [store, setStore] = createStore<{
methodIndex?: number
authorization?: Authorization
formAnswer?: FormAnswer
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
// Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial
// value would keep multi-method providers on the spinner instead of the method list.
state?: "pending" | "waiting" | "refreshing" | "ready" | "error" | "form"
error?: string
auto: boolean
connected: boolean
browserFailed: boolean
statusFailed: boolean
}>({
state: undefined as "pending" | "waiting" | "refreshing" | "ready" | "error" | "form" | undefined,
error: undefined as string | undefined,
auto: false,
// The credential is stored; a retry only needs to reload the catalogs.
connected: false,
@@ -138,9 +124,12 @@ export function createProviderConnectionController(options: {
statusFailed: false,
})
const polling: Polling = {
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt: undefined as Authorization | undefined,
}
const currentMethod = createMemo(() =>
@@ -217,6 +206,8 @@ export function createProviderConnectionController(options: {
)
}
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error))
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
@@ -285,7 +276,7 @@ export function createProviderConnectionController(options: {
setStore("statusFailed", true)
dispatch({
type: "auth.error",
error: isConsole() ? language.t("provider.connect.console.statusFailed") : formatServerError(result.error, language.t),
error: isConsole() ? language.t("provider.connect.console.statusFailed") : errorMessage(result.error),
})
return
@@ -367,6 +358,8 @@ export function createProviderConnectionController(options: {
return
}
if (selected.type !== "oauth") return
if (selected.form?.some((field) => field.type !== "string")) {
dispatch({ type: "auth.error", error: language.t("provider.connect.error.unsupportedFields") })
@@ -375,35 +368,11 @@ export function createProviderConnectionController(options: {
dispatch({ type: "auth.pending" })
if (selected.type === "external") {
const saved = await serverSDK.api.integration.connect
.external({
integrationID: options.provider(),
methodID: selected.id,
answer: Object.keys(merged).length ? merged : undefined,
location: location(),
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!saved.ok) {
dispatch({ type: "auth.error", error: formatServerError(saved.error, language.t) })
return
}
await finish()
return
}
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
methodID: selected.id,
answer: Object.keys(merged).length ? merged : undefined,
...(Object.keys(merged).length ? { answer: merged } : {}),
location: location(),
})
.then((response) => {
@@ -435,7 +404,7 @@ export function createProviderConnectionController(options: {
if (!result.ok) {
dispatch({
type: "auth.error",
error: isConsole() ? language.t("provider.connect.console.startFailed") : formatServerError(result.error, language.t),
error: isConsole() ? language.t("provider.connect.console.startFailed") : errorMessage(result.error),
})
return
@@ -479,7 +448,7 @@ export function createProviderConnectionController(options: {
integrationID: options.keyProvider?.() ?? options.provider(),
location: location(),
key,
answer: store.formAnswer,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
})
await finish()
}
@@ -499,8 +468,7 @@ export function createProviderConnectionController(options: {
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok)
return formatServerError(result.error, language.t, language.t("provider.connect.oauth.code.invalid"))
if (!result.ok) return errorMessage(result.error) || language.t("provider.connect.oauth.code.invalid")
await finish()
return undefined
+28 -128
View File
@@ -3,7 +3,6 @@ import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { List } from "@opencode/ui/list"
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
import { Spinner } from "@opencode/ui/spinner"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
@@ -62,7 +61,7 @@ type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController() {
const [store, setStore] = createStore<{ selected?: string }>({})
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const reset = () => setStore("selected", undefined)
return {
@@ -84,13 +83,9 @@ export const DialogConnectProvider: Component<{
const fallback = useProviderConnectController()
const controller = props.controller ?? fallback
const [state, setState] = createStore<{
completed: boolean
modelProvider?: { id: string; name: string }
authorization: boolean
chatgptWelcome: boolean
}>({
const [state, setState] = createStore({
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
chatgptWelcome: false,
})
@@ -221,12 +216,10 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const integrations = useIntegrations(() => props.directory)
const language = useLanguage()
const [store, setStore] = createStore<{
filter: string
active?: string
connecting?: string
}>({
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
@@ -428,24 +421,16 @@ function ProviderConnection(props: {
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
const remote = isConsole && authServerName(sdk.server) !== undefined
const [state, setState] = createStore<{
copied: boolean
copyFailed: boolean
firstConnection?: boolean
models: boolean
noModels: boolean
catalogPending: boolean
selectedModel: string
collapsed: Record<string, boolean>
}>({
const [state, setState] = createStore({
copied: false,
copyFailed: false,
firstConnection: undefined as boolean | undefined,
models: false,
noModels: false,
// The workspace providers had not loaded when the wait ran out.
catalogPending: false,
selectedModel: "",
collapsed: {},
collapsed: {} as Record<string, boolean>,
})
const controller = createProviderConnectionController({
@@ -657,22 +642,11 @@ function ProviderConnection(props: {
function AuthFormView() {
const defaults = providerFormDefaults(controller.currentMethod()?.form)
const [formStore, setFormStore] = createStore<{
value: Record<string, string>
index: number
customOn: boolean
customText: string
}>({
const [formStore, setFormStore] = createStore({
value: Object.fromEntries(
(controller.currentMethod()?.form ?? []).flatMap((field) =>
field.type === "string" && field.key in defaults && field.default !== undefined
? [[field.key, field.default]]
: [],
),
),
Object.entries(defaults).flatMap(([key, value]) => (typeof value === "string" ? [[key, value]] : [])),
) as Record<string, string>,
index: 0,
customOn: false,
customText: "",
})
const fields = createMemo<StringForm[]>(() => {
@@ -703,18 +677,14 @@ function ProviderConnection(props: {
}
})
// Visible defaults prefill the field but enter the answer only once that field is answered,
// because the server rejects values for fields whose conditions are not met.
const answer = (field: StringForm) => formStore.value[field.key] ?? field.default
const valid = createMemo(() => {
const item = current()
if (!item || (item.field.options && !item.field.custom)) return false
if (!item || item.field.options) return false
if (!item.field.required) return true
return (answer(item.field) ?? "").trim().length > 0
return (formStore.value[item.field.key] ?? "").trim().length > 0
})
async function next(index: number, value: Record<string, string>) {
@@ -724,7 +694,7 @@ function ProviderConnection(props: {
const next = fields().findIndex((field, i) => i > index && matches(field, value))
if (next !== -1) {
setFormStore({ index: next, customOn: false, customText: "" })
setFormStore("index", next)
return
}
@@ -736,10 +706,9 @@ function ProviderConnection(props: {
e.preventDefault()
const item = current()
if (!item || !valid()) return
const value = answer(item.field)
if (!item || item.field.options) return
if (value !== undefined) setFormStore("value", item.field.key, value)
if (!valid()) return
await next(item.index, formStore.value)
}
@@ -761,15 +730,6 @@ function ProviderConnection(props: {
return field
})
const custom = () => {
const field = select()
if (!field?.custom) return false
const value = answer(field)
return formStore.customOn || (value !== undefined && !field.options?.some((option) => option.value === value))
}
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
@@ -778,7 +738,7 @@ function ProviderConnection(props: {
type="text"
label={text()?.title ?? ""}
placeholder={text()?.placeholder}
value={text() ? (answer(text()!) ?? "") : ""}
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => {
const field = text()
@@ -790,62 +750,6 @@ function ProviderConnection(props: {
{language.t("common.continue")}
</Button>
</Match>
<Match when={select()?.custom && select()}>
{(field) => (
<>
<div class="w-full flex flex-col gap-3">
<div class="text-14-regular text-text-base">{field().title}</div>
<RadioGroup
label={field().title}
hideLabel
description={field().description}
value={
custom()
? "custom"
: String(field().options?.findIndex((option) => option.value === answer(field())))
}
onChange={(value) => {
const option = field().options?.[Number(value)]
if (option) {
if (custom()) setFormStore("customText", answer(field()) ?? "")
setFormStore("customOn", false)
setFormStore("value", field().key, option.value)
return
}
setFormStore("customOn", true)
setFormStore("value", field().key, formStore.customText)
}}
>
<For each={field().options}>
{(option, index) => (
<RadioItem value={String(index())} label={option.label} description={option.description} />
)}
</For>
<RadioItem value="custom" label={language.t("ui.messagePart.option.typeOwnAnswer")} />
<Show when={custom()}>
<div class="pl-6">
<TextField
ref={(input: HTMLInputElement) => queueMicrotask(() => input.focus())}
type="text"
label={language.t("ui.messagePart.option.typeOwnAnswer")}
hideLabel
placeholder={field().placeholder ?? language.t("ui.question.custom.placeholder")}
value={answer(field()) ?? ""}
onChange={(value) => setFormStore("value", field().key, value)}
/>
</div>
</Show>
</RadioGroup>
</div>
<Button class="w-auto" type="submit" size="large" variant="contrast" disabled={!valid()}>
{language.t("common.continue")}
</Button>
</>
)}
</Match>
<Match when={item()?.field.options !== undefined}>
<div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.title}</div>
@@ -854,7 +758,7 @@ function ProviderConnection(props: {
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options?.find((x) => x.value === answer(select()!))}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
if (!value) return
const field = select()
@@ -972,11 +876,9 @@ function ProviderConnection(props: {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore<{
value: string
error?: string
}>({
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
@@ -988,16 +890,16 @@ function ProviderConnection(props: {
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("apiKey")
const key = value instanceof File || value === null ? "" : value
const apiKey = typeof value === "string" ? value : ""
if (!key.trim()) {
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await controller.auth.connectKey(key)
await controller.auth.connectKey(apiKey)
}
return (
@@ -1052,11 +954,9 @@ function ProviderConnection(props: {
let codeInput: HTMLInputElement | undefined
const errorID = createUniqueId()
const [formStore, setFormStore] = createStore<{
value: string
error?: string
}>({
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
@@ -1068,9 +968,9 @@ function ProviderConnection(props: {
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("code")
const code = value instanceof File || value === null ? "" : value
const code = typeof value === "string" ? value : ""
if (!code.trim()) {
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
@@ -69,7 +69,6 @@ type Attached = {
keybind: (command: string) => readonly string[]
matches: (command: string, event: KeyboardEvent) => boolean
servers: Accessor<readonly string[]>
server: (id: string) => ServerRef | undefined
}
/**
@@ -270,10 +269,7 @@ export function createHostApis() {
keys: (bind) => formatKeybindParts(bind, language.t),
matches: (command, event) => current()?.matches(command, event) ?? false,
}),
servers: () => ({
list: () => current()?.servers() ?? [],
get: (id) => current()?.server(id),
}),
servers: () => ({ list: () => current()?.servers() ?? [] }),
workspaces: (_extension, _owner, _context, register) => ({
on(_event, handler) {
removed.add(handler)
@@ -393,28 +389,15 @@ export function createExtensionAttachment(apis: HostApis) {
if (existing) return existing
const key = ServerConnection.Key.make(id)
// The controller starts on first use, not when the ref is made: reading `builtin` to find a server must not
// connect to every listed server, such as one the runtime holds back because it rejects its credentials.
let started: Accessor<ServerCtx> | undefined
const live = () => {
started ??= runWithOwner(owner, () =>
createMemo<ServerCtx>(
(previous) => global.serverCtx(key) ?? previous,
global.ensureServerCtx(connection(id) ?? conn),
),
)!
return started()
}
// What the app's server list already says, without a controller.
const listed = () => connection(id) ?? live().sdk.server
const live = runWithOwner(owner, () =>
createMemo<ServerCtx>((previous) => global.serverCtx(key) ?? previous, global.ensureServerCtx(conn)),
)!
const ref: ServerRef = {
id,
get name() {
return serverName(listed()) || id
return serverName(live().sdk.server) || id
},
get url() {
return live().sdk.url
@@ -429,10 +412,10 @@ export function createExtensionAttachment(apis: HostApis) {
return live().data
},
get local() {
return ServerConnection.local(listed())
return ServerConnection.local(live().sdk.server)
},
get builtin() {
return ServerConnection.builtin(listed())
return ServerConnection.builtin(live().sdk.server)
},
get compatible() {
return !global.servers.health[key]?.incompatible
@@ -800,7 +783,6 @@ export function createExtensionAttachment(apis: HostApis) {
keybind: command.keybindParts,
matches: command.matches,
servers: () => global.servers.list().map(ServerConnection.key),
server,
// SAFETY: an extension names a page it contributed through `SettingsPage`, which settings lists as an extension tab.
settings: (page) => surface.open(page as Parameters<typeof surface.open>[0]),
layout: {
+2
View File
@@ -775,12 +775,14 @@ export const dict = {
"server.connect.button": "መገናኘት",
"server.connect.address.invalid": "የሚሰራ የኤችቲቲፒ ወይም የኤችቲቲፒኤስ አገልጋይ አድራሻ ያስገቡ።",
"server.connect.failed": "መገናኘት አልተቻለም። የአገልጋዩን አድራሻ እና የይለፍ ቃል ያረጋግጡ እና ከዚያ እንደገና ይሞክሩ።",
"server.connect.pair.description": "የግንኙነት ዝርዝሮችዎን ለማግኘት ይህንን ትዕዛዝ በኮምፒተርዎ ላይ ያሂዱ።",
"server.connect.scan": "QR ኮድ ይቃኙ",
"server.connect.scan.description": "ካሜራዎን opencode pair ወደሚያሳየው QR ኮድ ያነጣጥሩ።",
"server.connect.scan.invalid": "ይህ የOpenCode ማጣመሪያ ኮድ አይደለም። opencode pair የሚያሳየውን ኮድ ይቃኙ።",
"server.connect.camera": "ማጣመር ካሜራ",
"server.connect.camera.starting": "ካሜራ በመክፈት ላይ...",
"server.connect.mixedContent": "ከዚህ ኤችቲቲፒኤስ አገልጋይ ጋር ከኤችቲቲፒኤስ ገጽ ጋር መገናኘት አልተቻለም። በምትኩ የኤችቲቲፒኤስ አገልጋይ አድራሻ ይጠቀሙ።",
"server.connect.camera.insecure": "QR ቅኝት ይህንን ገጽ በኤችቲቲፒኤስ ወይም በአካባቢያዊ አስተናጋጅ መክፈት ይጠይቃል።",
"server.connect.camera.unavailable": "ለዚህ አሳሽ ምንም ካሜራ አይገኝም። የግንኙነት ዝርዝሮችዎን እራስዎ ያስገቡ።",
"server.connect.camera.error": "ካሜራውን መክፈት አልተቻለም። የካሜራ መዳረሻ ይፍቀዱ ወይም የግንኙነት ዝርዝሮችዎን እራስዎ ያስገቡ።",
"dialog.server.menu.remove": "አስወግድ",
+2
View File
@@ -833,12 +833,14 @@ export const dict = {
"server.connect.button": "اتصال",
"server.connect.address.invalid": "أدخل عنوان خادم HTTP أو HTTPS صالحًا.",
"server.connect.failed": "تعذر الاتصال. تحقق من عنوان الخادم وكلمة المرور، ثم أعد المحاولة.",
"server.connect.pair.description": "نفّذ هذا الأمر على حاسوبك للحصول على تفاصيل الاتصال.",
"server.connect.scan": "مسح رمز QR",
"server.connect.scan.description": "وجّه الكاميرا نحو رمز QR الذي يعرضه opencode pair.",
"server.connect.scan.invalid": "هذا ليس رمز إقران OpenCode. امسح الرمز الذي يعرضه opencode pair.",
"server.connect.camera": "كاميرا الإقران",
"server.connect.camera.starting": "جارٍ فتح الكاميرا…",
"server.connect.mixedContent": "تعذر الاتصال بخادم HTTP هذا من صفحة HTTPS. استخدم عنوان خادم HTTPS بدلًا منه.",
"server.connect.camera.insecure": "يتطلب مسح رمز QR فتح هذه الصفحة عبر HTTPS أو على localhost.",
"server.connect.camera.unavailable": "لا تتوفر كاميرا لهذا المتصفح. أدخل تفاصيل الاتصال يدويًا.",
"server.connect.camera.error": "تعذر فتح الكاميرا. اسمح بالوصول إليها أو أدخل تفاصيل الاتصال يدويًا.",
"dialog.server.menu.remove": "إزالة",
+3
View File
@@ -799,6 +799,7 @@ export const dict = {
"server.connect.button": "Qoşulun",
"server.connect.address.invalid": "Etibarlı HTTP və ya HTTPS server ünvanını daxil edin.",
"server.connect.failed": "Bağlana bilmədik. Server ünvanını və şifrəni yoxlayın, sonra yenidən cəhd edin.",
"server.connect.pair.description": "Bağlantı məlumatlarınızı almaq üçün bu əmri kompüterinizdə işlədin.",
"server.connect.scan": "QR kodu skan edin",
"server.connect.scan.description": "Kameranızı opencode pair tərəfindən göstərilən QR koda yönəldin.",
"server.connect.scan.invalid": "Bu, OpenCode qoşulma kodu deyil. opencode pair tərəfindən göstərilən kodu skan edin.",
@@ -806,6 +807,8 @@ export const dict = {
"server.connect.camera.starting": "Kamera açılır…",
"server.connect.mixedContent":
"Bu HTTPS səhifəsindən bu HTTP serverinə qoşulmaq mümkün olmadı. Bunun əvəzinə HTTPS server ünvanından istifadə edin.",
"server.connect.camera.insecure":
"QR skan edilməsi üçün bu səhifəni HTTPS üzərindən və ya localhost-da açmaq tələb olunur.",
"server.connect.camera.unavailable":
"Bu brauzer üçün heç bir kamera mövcud deyil. Bağlantı məlumatlarınızı əl ilə daxil edin.",
"server.connect.camera.error":
+3
View File
@@ -797,6 +797,7 @@ export const dict = {
"server.connect.button": "Свързване",
"server.connect.address.invalid": "Въведете валиден HTTP или HTTPS адрес на сървър.",
"server.connect.failed": "Свързването е неуспешно. Проверете адреса и паролата на сървъра и опитайте отново.",
"server.connect.pair.description": "Изпълнете тази команда на компютъра си, за да получите данните за връзка.",
"server.connect.scan": "Сканиране на QR код",
"server.connect.scan.description": "Насочете камерата към QR кода, показан от opencode pair.",
"server.connect.scan.invalid": "Това не е код за сдвояване с OpenCode. Сканирайте кода, показан от opencode pair.",
@@ -804,6 +805,8 @@ export const dict = {
"server.connect.camera.starting": "Отваряне на камерата…",
"server.connect.mixedContent":
"Не може да се осъществи връзка с този HTTP сървър от HTTPS страница. Използвайте HTTPS адрес на сървъра.",
"server.connect.camera.insecure":
"Сканирането на QR код изисква тази страница да е отворена чрез HTTPS или на localhost.",
"server.connect.camera.unavailable": "Няма достъпна камера за този браузър. Въведете ръчно данните за връзка.",
"server.connect.camera.error":
"Камерата не можа да бъде отворена. Разрешете достъпа до нея или въведете ръчно данните за връзка.",
+2
View File
@@ -797,6 +797,7 @@ export const dict = {
"server.connect.button": "সংযোগ করুন",
"server.connect.address.invalid": "একটি বৈধ HTTP বা HTTPS সার্ভার ঠিকানা লিখুন৷",
"server.connect.failed": "সংযোগ করা যায়নি। সার্ভার ঠিকানা এবং পাসওয়ার্ড পরীক্ষা করুন, তারপর আবার চেষ্টা করুন.",
"server.connect.pair.description": "আপনার সংযোগের বিবরণ পেতে আপনার কম্পিউটারে এই কমান্ডটি চালান।",
"server.connect.scan": "QR কোড স্ক্যান করুন",
"server.connect.scan.description": "আপনার ক্যামেরাকে ওপেনকোড পেয়ার দ্বারা দেখানো QR কোডে নির্দেশ করুন৷",
"server.connect.scan.invalid": "এটি একটি OpenCode পেয়ারিং কোড নয়৷ ওপেনকোড পেয়ার দ্বারা দেখানো কোডটি স্ক্যান করুন।",
@@ -804,6 +805,7 @@ export const dict = {
"server.connect.camera.starting": "ক্যামেরা খোলা হচ্ছে...",
"server.connect.mixedContent":
"একটি HTTPS পৃষ্ঠা থেকে এই HTTP সার্ভারের সাথে সংযোগ করা যায়নি৷ পরিবর্তে একটি HTTPS সার্ভার ঠিকানা ব্যবহার করুন৷",
"server.connect.camera.insecure": "QR স্ক্যান করার জন্য এই পৃষ্ঠাটি HTTPS বা লোকালহোস্টে খুলতে হবে।",
"server.connect.camera.unavailable": "এই ব্রাউজারে কোন ক্যামেরা উপলব্ধ নেই৷ ম্যানুয়ালি আপনার সংযোগ বিশদ লিখুন.",
"server.connect.camera.error":
"ক্যামেরা খুলতে পারিনি। ক্যামেরা অ্যাক্সেসের অনুমতি দিন বা ম্যানুয়ালি আপনার সংযোগের বিবরণ লিখুন।",
+2
View File
@@ -22,6 +22,7 @@ export const dict = {
"server.connect.button": "Conectar",
"server.connect.address.invalid": "Insira um endereço de servidor HTTP ou HTTPS válido.",
"server.connect.failed": "Não foi possível conectar. Verifique o endereço e a senha do servidor e tente novamente.",
"server.connect.pair.description": "Execute este comando em seu computador para obter os detalhes de sua conexão.",
"server.connect.scan": "Digitalize o código QR",
"server.connect.scan.description": "Aponte sua câmera para o código QR mostrado por opencode pair.",
"server.connect.scan.invalid":
@@ -30,6 +31,7 @@ export const dict = {
"server.connect.camera.starting": "Abrindo a câmera…",
"server.connect.mixedContent":
"Não foi possível conectar-se a este servidor HTTP a partir de uma página HTTPS. Use um endereço de servidor HTTPS.",
"server.connect.camera.insecure": "A varredura QR requer a abertura desta página em HTTPS ou no host local.",
"server.connect.camera.unavailable":
"Nenhuma câmera está disponível para este navegador. Insira os detalhes da sua conexão manualmente.",
"server.connect.camera.error":
+3
View File
@@ -862,6 +862,7 @@ export const dict = {
"server.connect.button": "Poveži se",
"server.connect.address.invalid": "Unesite ispravnu HTTP ili HTTPS adresu servera.",
"server.connect.failed": "Povezivanje nije uspjelo. Provjerite adresu servera i lozinku, pa pokušajte ponovo.",
"server.connect.pair.description": "Pokrenite ovu naredbu na računaru da biste dobili podatke za povezivanje.",
"server.connect.scan": "Skeniraj QR kod",
"server.connect.scan.description": "Usmjerite kameru prema QR kodu koji prikazuje opencode pair.",
"server.connect.scan.invalid": "Ovo nije OpenCode kod za uparivanje. Skenirajte kod koji prikazuje opencode pair.",
@@ -869,6 +870,8 @@ export const dict = {
"server.connect.camera.starting": "Otvaranje kamere…",
"server.connect.mixedContent":
"Nije se moguće povezati s ovim HTTP serverom s HTTPS stranice. Umjesto toga koristite HTTPS adresu servera.",
"server.connect.camera.insecure":
"Za skeniranje QR koda ovu stranicu morate otvoriti putem HTTPS-a ili na localhostu.",
"server.connect.camera.unavailable": "Ovom pregledniku nije dostupna kamera. Ručno unesite podatke za povezivanje.",
"server.connect.camera.error":
"Kameru nije bilo moguće otvoriti. Dozvolite pristup kameri ili ručno unesite podatke za povezivanje.",
+3
View File
@@ -796,6 +796,8 @@ export const dict = {
"server.connect.address.invalid": "Introduïu una adreça de servidor HTTP o HTTPS vàlida.",
"server.connect.failed":
"No s'ha pogut connectar. Comproveu l'adreça i la contrasenya del servidor i torneu-ho a provar.",
"server.connect.pair.description":
"Executeu aquesta ordre al vostre ordinador per obtenir els detalls de la vostra connexió.",
"server.connect.scan": "Escaneja el codi QR",
"server.connect.scan.description": "Apunteu la càmera al codi QR que mostra opencode pair.",
"server.connect.scan.invalid":
@@ -804,6 +806,7 @@ export const dict = {
"server.connect.camera.starting": "Obertura de la càmera...",
"server.connect.mixedContent":
"No s'ha pogut connectar a aquest servidor HTTP des d'una pàgina HTTPS. Utilitzeu una adreça de servidor HTTPS.",
"server.connect.camera.insecure": "L'exploració de QR requereix obrir aquesta pàgina a HTTPS o a localhost.",
"server.connect.camera.unavailable":
"No hi ha cap càmera disponible per a aquest navegador. Introduïu els detalls de connexió manualment.",
"server.connect.camera.error":
+2
View File
@@ -806,6 +806,7 @@ export const dict = {
"server.connect.button": "Připojit",
"server.connect.address.invalid": "Zadejte platnou adresu serveru HTTP nebo HTTPS.",
"server.connect.failed": "Připojení se nezdařilo. Zkontrolujte adresu serveru a heslo a zkuste to znovu.",
"server.connect.pair.description": "Spuštěním tohoto příkazu v počítači získáte údaje pro připojení.",
"server.connect.scan": "Naskenovat QR kód",
"server.connect.scan.description": "Namiřte kameru na QR kód zobrazený příkazem opencode pair.",
"server.connect.scan.invalid": "Toto není párovací kód OpenCode. Naskenujte kód zobrazený příkazem opencode pair.",
@@ -813,6 +814,7 @@ export const dict = {
"server.connect.camera.starting": "Otevírání kamery…",
"server.connect.mixedContent":
"K tomuto serveru HTTP se nelze připojit ze stránky HTTPS. Použijte adresu serveru HTTPS.",
"server.connect.camera.insecure": "Skenování QR kódu vyžaduje otevření této stránky přes HTTPS nebo na localhostu.",
"server.connect.camera.unavailable":
"Pro tento prohlížeč není dostupná žádná kamera. Zadejte údaje pro připojení ručně.",
"server.connect.camera.error":
+2
View File
@@ -23,6 +23,7 @@ export const dict = {
"server.connect.address.invalid": "Indtast en gyldig HTTP- eller HTTPS-serveradresse.",
"server.connect.failed":
"Kunne ikke oprette forbindelse. Tjek serveradressen og adgangskoden, og prøv derefter igen.",
"server.connect.pair.description": "Kør denne kommando på din computer for at få dine forbindelsesdetaljer.",
"server.connect.scan": "Scan QR kode",
"server.connect.scan.description": "Ret dit kamera mod QR-koden vist af opencode pair.",
"server.connect.scan.invalid": "Dette er ikke en OpenCode-parringskode. Scan koden vist af opencode pair.",
@@ -30,6 +31,7 @@ export const dict = {
"server.connect.camera.starting": "Åbner kamera...",
"server.connect.mixedContent":
"Kunne ikke oprette forbindelse til denne HTTP-server fra en HTTPS-side. Brug i stedet en HTTPS-serveradresse.",
"server.connect.camera.insecure": "QR-scanning kræver, at denne side åbnes over HTTPS eller på localhost.",
"server.connect.camera.unavailable":
"Intet kamera er tilgængeligt for denne browser. Indtast dine forbindelsesoplysninger manuelt.",
"server.connect.camera.error":
+4
View File
@@ -27,6 +27,8 @@ export const dict = {
"server.connect.address.invalid": "Geben Sie eine gültige HTTP- oder HTTPS-Serveradresse ein.",
"server.connect.failed":
"Es konnte keine Verbindung hergestellt werden. Überprüfen Sie die Serveradresse und das Passwort und versuchen Sie es dann erneut.",
"server.connect.pair.description":
"Führen Sie diesen Befehl auf Ihrem Computer aus, um Ihre Verbindungsdetails abzurufen.",
"server.connect.scan": "Scannen Sie den QR-Code",
"server.connect.scan.description": "Richten Sie Ihre Kamera auf den von opencode pair angezeigten Code QR.",
"server.connect.scan.invalid":
@@ -35,6 +37,8 @@ export const dict = {
"server.connect.camera.starting": "Kamera wird geöffnet…",
"server.connect.mixedContent":
"Es konnte keine Verbindung zu diesem HTTP-Server von einer HTTPS-Seite hergestellt werden. Verwenden Sie stattdessen eine HTTPS-Serveradresse.",
"server.connect.camera.insecure":
"Zum Scannen mit QR muss diese Seite über HTTPS oder auf localhost geöffnet werden.",
"server.connect.camera.unavailable":
"Für diesen Browser ist keine Kamera verfügbar. Geben Sie Ihre Verbindungsdaten manuell ein.",
"server.connect.camera.error":
+3
View File
@@ -816,6 +816,7 @@ export const dict = {
"server.connect.address.invalid": "ސައްހަ HTTP ނުވަތަ HTTPS ސަރވަރ އެޑްރެހެއް ލިޔެލާށެވެ.",
"server.connect.failed":
"ގުޅެވޭ ގޮތެއް ނުވިއެވެ. ސަރވަރ އެޑްރެހާއި ޕާސްވޯޑް ޗެކްކޮށްލުމަށްފަހު އަލުން މަސައްކަތް ކުރާށެވެ.",
"server.connect.pair.description": "ކަނެކްޝަންގެ ތަފްސީލު ހޯދުމަށްޓަކައި ކޮމްޕިއުޓަރުގައި މި ކޮމާންޑް ހިންގާށެވެ.",
"server.connect.scan": "QR ކޯޑް ސްކޭން ކުރާށެވެ",
"server.connect.scan.description": "އޮޕަންކޯޑް ޖޯޑުން ދައްކާ QR ކޯޑަށް ކެމެރާ އިޝާރާތް ކުރާށެވެ.",
"server.connect.scan.invalid": "މިއީ OpenCode ޖޯޑު ކޯޑެއް ނޫނެވެ. އޮޕަންކޯޑް ޕެއާ އިން ދައްކާ ކޯޑް ސްކޭން ކުރާށެވެ.",
@@ -823,6 +824,8 @@ export const dict = {
"server.connect.camera.starting": "ކެމެރާ ހުޅުވަނީ...",
"server.connect.mixedContent":
"HTTPS ޕޭޖަކުން މި HTTP ސަރވަރ އާއި ގުޅޭ ގޮތެއް ނުވިއެވެ. އޭގެ ބަދަލުގައި HTTPS ސަރވަރ އެޑްރެހެއް ބޭނުން ކުރާށެވެ.",
"server.connect.camera.insecure":
"QR ސްކޭން ކުރުމަށް މި ޕޭޖް ހުޅުވަން ޖެހެނީ HTTPS ގެ މައްޗަށް ނުވަތަ ލޯކަލްހޯސްޓް އިންނެވެ.",
"server.connect.camera.unavailable":
"މި ބްރައުޒާއަށް އެއްވެސް ކެމެރާއެއް ނުލިބެއެވެ. ކަނެކްޝަންގެ ތަފްސީލުތައް އަމިއްލައަށް ލިޔެލާށެވެ.",
"server.connect.camera.error":
+3
View File
@@ -797,6 +797,8 @@ export const dict = {
"server.connect.button": "མཐུད།",
"server.connect.address.invalid": "ནུས་ཅན་ HTTP ཡང་ན་ HTTPS སར་བར་ཁ་བྱང་ཅིག་བཙུགས།",
"server.connect.failed": "མཐུད་མ་ཚུགས། སར་བར་ཁ་བྱང་དང་ཆོག་ཡིག་ཞིབ་དཔྱད་འབད་ཞིནམ་ལས་ ལོག་སྟེ་འབད་རྩོལ་བསྐྱེད།",
"server.connect.pair.description":
"ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ཐོབ་ནིའི་དོན་ལུ་ ཁྱོད་རའི་གློག་རིག་གུ་བརྡ་བཀོད་འདི་གཡོག་བཀོལ།",
"server.connect.scan": "QR གསང་གྲངས་པར་བཤུས་",
"server.connect.scan.description": "ཁྱོད་རའི་པར་ཆས་འདི་ opencode pair གིས་སྟོན་མི་ QR གསང་ཡིག་ལུ་སྟོན་དགོ།",
"server.connect.scan.invalid":
@@ -805,6 +807,7 @@ export const dict = {
"server.connect.camera.starting": "པར་ཆས་ཁ་ཕྱེ་དོ...",
"server.connect.mixedContent":
"HTTPS ཤོག་ལེབ་ཅིག་ལས་ HTTP སར་བར་འདི་ལུ་མཐུད་མ་ཚུགས། དེ་གི་ཚབ་ལུ་ HTTPS སར་བར་ཁ་བྱང་ཅིག་ལག་ལེན་འཐབ།",
"server.connect.camera.insecure": "QR པར་ལེན་འབད་ནི་ལུ་ ཤོག་ལེབ་འདི་ HTTPS ཡང་ན་ localhost གུ་ཁ་ཕྱེ་དགོཔ་ཨིན།",
"server.connect.camera.unavailable": "བརྡ་འཚོལ་འདི་ལུ་པར་ཆས་མེད། ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ལག་ཐོག་ལས་བཙུགས།",
"server.connect.camera.error":
"པར་ཆས་ཁ་ཕྱེ་མ་ཚུགས། པར་ཆས་འཛུལ་སྤྱོད་འབད་བཅུགཔ་ ཡང་ན་ ཁྱོད་རའི་མཐུད་ལམ་རྒྱས་བཤད་ཚུ་ལག་ཐོག་ལས་བཙུགས།",
+3
View File
@@ -796,6 +796,8 @@ export const dict = {
"server.connect.address.invalid": "Εισαγάγετε μια έγκυρη διεύθυνση διακομιστή HTTP ή HTTPS.",
"server.connect.failed":
"Δεν ήταν δυνατή η σύνδεση. Ελέγξτε τη διεύθυνση του διακομιστή και τον κωδικό πρόσβασης και, στη συνέχεια, δοκιμάστε ξανά.",
"server.connect.pair.description":
"Εκτελέστε αυτήν την εντολή στον υπολογιστή σας για να λάβετε τα στοιχεία της σύνδεσής σας.",
"server.connect.scan": "Σάρωση κωδικού QR",
"server.connect.scan.description": "Στρέψτε την κάμερά σας στον κωδικό QR που εμφανίζεται από το opencode pair.",
"server.connect.scan.invalid":
@@ -804,6 +806,7 @@ export const dict = {
"server.connect.camera.starting": "Άνοιγμα κάμερας…",
"server.connect.mixedContent":
"Δεν ήταν δυνατή η σύνδεση σε αυτόν τον διακομιστή HTTP από μια σελίδα HTTPS. Χρησιμοποιήστε μια διεύθυνση διακομιστή HTTPS.",
"server.connect.camera.insecure": "Η σάρωση QR απαιτεί άνοιγμα αυτής της σελίδας μέσω HTTPS ή localhost.",
"server.connect.camera.unavailable":
"Δεν υπάρχει κάμερα διαθέσιμη σε αυτό το πρόγραμμα περιήγησης. Εισαγάγετε τα στοιχεία της σύνδεσής σας με μη αυτόματο τρόπο.",
"server.connect.camera.error":
+2 -34
View File
@@ -340,41 +340,24 @@ export const dict = {
"server.connect.title": "Connect to a server",
"server.connect.description": "Enter your server address and password to get started.",
"server.connect.description.pairing": "Paste the pairing link from the server, or scan its QR code.",
"server.connect.link": "Pairing link",
"server.connect.link.placeholder": "http://192.168.1.2:4096/auth/connect/…",
"server.connect.link.invalid":
"Paste the full pairing link from opencode pair, or from Settings → Pairing on the server's computer.",
"server.connect.password.prompt": "No pairing link?",
"server.connect.password.use": "Use password",
"server.connect.password.hint": "To see it, run opencode service get password on the server's computer.",
"server.connect.link.prompt": "Have a pairing link?",
"server.connect.link.use": "Use pairing link",
"server.connect.button": "Connect",
"server.connect.address.invalid": "Enter a valid HTTP or HTTPS server address.",
"server.connect.failed": "Could not connect. Check the server address and password, then try again.",
"server.connect.pair.link": "Run this command on the server's computer to get a pairing link and QR code.",
"server.connect.pair.description": "Run this command on your computer to get your connection details.",
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.scan.failed": "This browser could not read the QR code. Enter your connection details manually.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.link.unreachable":
"Could not reach {{url}}. Check that this device can reach the server, then try again.",
"server.connect.link.legacy":
"This pairing code is from an older version of OpenCode. Update OpenCode on the server, then run opencode pair again.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
"Could not connect to this HTTP server from an HTTPS page. Use an HTTPS server address instead.",
"server.connect.camera.native": "This page cannot use the camera over HTTP. Paste the pairing link above.",
"server.connect.camera.insecure": "QR scanning requires opening this page over HTTPS or on localhost.",
"server.connect.camera.unavailable":
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"dialog.server.edit.title": "Edit server",
"dialog.server.signedOut":
"Your sign-in for this server is no longer valid. Paste a new pairing link, or enter the server password.",
"dialog.server.menu.edit": "Edit",
"dialog.server.menu.default": "Set as default",
@@ -679,21 +662,6 @@ export const dict = {
"settings.about.description": "OpenCode, the open source coding agent",
"settings.about.trademark": "OpenCode is a registered trademark of Anomaly Innovations, Inc.",
"settings.about.typeset": "Typeset in Inter and IBM Plex Mono",
"settings.about.notices.title": "Third-party notices",
"settings.about.notices.description": "OpenCode includes the following open source software.",
"settings.about.notices.license": "License text",
"settings.about.notices.fonts.title": "Fonts in the Office previews",
"settings.about.notices.fonts.description":
"Each family is distributed under the SIL Open Font License 1.1, with the copyright lines below.",
"settings.about.notices.eigenpal": "Portions of BetterOffice derive from it.",
"settings.about.notices.loadFailed": "Could not load the third-party notices",
"settings.about.notices.crates.title": "Rust crates in the Office engines",
"settings.about.notices.crates.description":
"BetterOffice compiles these crates into its Word, Excel and PowerPoint engines. Where a crate offers a choice of licenses, the highlighted license applies.",
"settings.about.notices.crates.licenses": "License texts",
"settings.about.notices.crates.count.one": "{{count}} crate",
"settings.about.notices.crates.count.other": "{{count}} crates",
"settings.about.notices.crates.appliesTo": "Applies to {{names}}",
"settings.about.tagline": "AI can’t build great software, without you",
"settings.about.copyright": "© Anomaly Innovations, Inc.",
"settings.preferences.description": "Customize preferences and theme and default behavior",
+2
View File
@@ -23,6 +23,7 @@ export const dict = {
"server.connect.address.invalid": "Ingrese una dirección de servidor HTTP o HTTPS válida.",
"server.connect.failed":
"No se pudo conectar. Verifique la dirección y la contraseña del servidor y vuelva a intentarlo.",
"server.connect.pair.description": "Ejecute este comando en su computadora para obtener los detalles de su conexión.",
"server.connect.scan": "Escanea el código QR",
"server.connect.scan.description": "Apunte su cámara al código QR mostrado por opencode pair.",
"server.connect.scan.invalid":
@@ -31,6 +32,7 @@ export const dict = {
"server.connect.camera.starting": "Abriendo cámara...",
"server.connect.mixedContent":
"No se pudo conectar a este servidor HTTP desde una página HTTPS. Utilice una dirección de servidor HTTPS en su lugar.",
"server.connect.camera.insecure": "El escaneo de QR requiere abrir esta página en HTTPS o en localhost.",
"server.connect.camera.unavailable":
"No hay ninguna cámara disponible para este navegador. Ingrese los detalles de su conexión manualmente.",
"server.connect.camera.error":
+2
View File
@@ -775,6 +775,7 @@ export const dict = {
"server.connect.button": "Ühendage",
"server.connect.address.invalid": "Sisestage kehtiv serveri aadress HTTP või HTTPS.",
"server.connect.failed": "Ühendust ei õnnestunud luua. Kontrollige serveri aadressi ja parooli ning proovige uuesti.",
"server.connect.pair.description": "Ühenduse üksikasjade hankimiseks käivitage see käsk oma arvutis.",
"server.connect.scan": "Skaneeri QR-kood",
"server.connect.scan.description": "Suunake oma kaamera koodile QR, mida näitab opencode pair.",
"server.connect.scan.invalid": "See ei ole OpenCode sidumiskood. Skannige koodi, mida näitab opencode pair.",
@@ -782,6 +783,7 @@ export const dict = {
"server.connect.camera.starting": "Kaamera avamine…",
"server.connect.mixedContent":
"Selle HTTP serveriga ei saanud HTTPS lehelt ühendust luua. Kasutage selle asemel HTTPS serveri aadressi.",
"server.connect.camera.insecure": "QR skannimine nõuab selle lehe avamist HTTPS või localhost kaudu.",
"server.connect.camera.unavailable":
"Selle brauseri jaoks pole kaamerat saadaval. Sisestage ühenduse üksikasjad käsitsi.",
"server.connect.camera.error":
+2
View File
@@ -795,6 +795,7 @@ export const dict = {
"server.connect.button": "اتصال",
"server.connect.address.invalid": "یک نشانی معتبر سرور HTTP یا HTTPS وارد کنید.",
"server.connect.failed": "اتصال ممکن نشد. نشانی سرور و گذرواژه را بررسی کنید و دوباره تلاش کنید.",
"server.connect.pair.description": "برای دریافت جزئیات اتصال، این فرمان را روی رایانهٔ خود اجرا کنید.",
"server.connect.scan": "پویش کد QR",
"server.connect.scan.description": "دوربین را به‌سمت کد QR نمایش‌داده‌شده توسط opencode pair بگیرید.",
"server.connect.scan.invalid": "این کد جفت‌سازی OpenCode نیست. کدی را پویش کنید که opencode pair نشان می‌دهد.",
@@ -802,6 +803,7 @@ export const dict = {
"server.connect.camera.starting": "در حال باز کردن دوربین…",
"server.connect.mixedContent":
"اتصال به این سرور HTTP از صفحهٔ HTTPS ممکن نشد. به‌جای آن از نشانی سرور HTTPS استفاده کنید.",
"server.connect.camera.insecure": "پویش QR نیازمند باز کردن این صفحه از طریق HTTPS یا روی localhost است.",
"server.connect.camera.unavailable": "هیچ دوربینی برای این مرورگر در دسترس نیست. جزئیات اتصال را دستی وارد کنید.",
"server.connect.camera.error":
"باز کردن دوربین ممکن نشد. دسترسی دوربین را مجاز کنید یا جزئیات اتصال را دستی وارد کنید.",
+2
View File
@@ -23,6 +23,7 @@ export const dict = {
"server.connect.address.invalid": "Anna kelvollinen HTTP- tai HTTPS-palvelinosoite.",
"server.connect.failed":
"Yhteyttä ei voitu muodostaa. Tarkista palvelimen osoite ja salasana ja yritä sitten uudelleen.",
"server.connect.pair.description": "Suorita tämä komento tietokoneellasi saadaksesi yhteystiedot.",
"server.connect.scan": "Skannaa QR-koodi",
"server.connect.scan.description": "Suuntaa kamerasi QR-koodiin, joka näkyy opencode pair:ssä.",
"server.connect.scan.invalid": "Tämä ei ole OpenCode-pariliitoskoodi. Skannaa opencode pair:n näyttämä koodi.",
@@ -30,6 +31,7 @@ export const dict = {
"server.connect.camera.starting": "Avataan kameraa…",
"server.connect.mixedContent":
"Yhteyttä tähän HTTP-palvelimeen ei voitu muodostaa HTTPS-sivulta. Käytä sen sijaan HTTPS-palvelinosoitetta.",
"server.connect.camera.insecure": "QR-skannaus vaatii tämän sivun avaamisen HTTPS:n kautta tai localhostissa.",
"server.connect.camera.unavailable": "Kamera ei ole saatavilla tälle selaimelle. Syötä yhteystietosi manuaalisesti.",
"server.connect.camera.error": "Kameraa ei voitu avata. Salli kameran käyttö tai anna yhteystietosi manuaalisesti.",
"dialog.server.menu.remove": "Poista",
+2
View File
@@ -779,6 +779,7 @@ export const dict = {
"server.connect.button": "Samband",
"server.connect.address.invalid": "Skriva eina gylduga HTTP ella HTTPS ambætaraadressu.",
"server.connect.failed": "Fekk ikki samband. Kanna ambætaraadressuna og loyniorðið, og royn síðani aftur.",
"server.connect.pair.description": "Koyr hesa skipanina á tínari teldu fyri at fáa tínar sambandsupplýsingar.",
"server.connect.scan": "Skanna QR-kotu",
"server.connect.scan.description": "Peika myndatólið á QR-kotuna, sum opencode pair vísir.",
"server.connect.scan.invalid": "Hetta er ikki ein OpenCode paringarkoda. Skanna kotuna, sum opencode pair vísir.",
@@ -786,6 +787,7 @@ export const dict = {
"server.connect.camera.starting": "Opna myndatól...",
"server.connect.mixedContent":
"Kundi ikki seta samband við hendan HTTP ambætaran frá eini HTTPS síðu. Brúka eina HTTPS ambætaraadressu ístaðin.",
"server.connect.camera.insecure": "QR-skanning krevur, at tú letur hesa síðuna upp yvir HTTPS ella á localhost.",
"server.connect.camera.unavailable":
"Einki myndatól er tøkt til henda kaga. Skriva tínar sambandsupplýsingar manuelt.",
"server.connect.camera.error":
+3
View File
@@ -23,6 +23,8 @@ export const dict = {
"server.connect.address.invalid": "Entrez une adresse de serveur HTTP ou HTTPS valide.",
"server.connect.failed":
"Impossible de se connecter. Vérifiez l'adresse et le mot de passe du serveur, puis réessayez.",
"server.connect.pair.description":
"Exécutez cette commande sur votre ordinateur pour obtenir les détails de votre connexion.",
"server.connect.scan": "Scanner le code QR",
"server.connect.scan.description": "Pointez votre appareil photo vers le code QR affiché par opencode pair.",
"server.connect.scan.invalid":
@@ -31,6 +33,7 @@ export const dict = {
"server.connect.camera.starting": "Ouverture de la caméra…",
"server.connect.mixedContent":
"Impossible de se connecter à ce serveur HTTP à partir d'une page HTTPS. Utilisez plutôt une adresse de serveur HTTPS.",
"server.connect.camera.insecure": "L'analyse QR nécessite l'ouverture de cette page sur HTTPS ou sur localhost.",
"server.connect.camera.unavailable":
"Aucune caméra n'est disponible pour ce navigateur. Saisissez manuellement vos informations de connexion.",
"server.connect.camera.error":
+2
View File
@@ -793,12 +793,14 @@ export const dict = {
"server.connect.button": "התחברות",
"server.connect.address.invalid": "יש להזין כתובת שרת HTTP או HTTPS תקינה.",
"server.connect.failed": "לא ניתן להתחבר. יש לבדוק את כתובת השרת והסיסמה ולנסות שוב.",
"server.connect.pair.description": "יש להריץ פקודה זו במחשב כדי לקבל את פרטי החיבור.",
"server.connect.scan": "סריקת קוד QR",
"server.connect.scan.description": "יש לכוון את המצלמה לקוד QR שמוצג על ידי opencode pair.",
"server.connect.scan.invalid": "זה אינו קוד צימוד של OpenCode. יש לסרוק את הקוד שמוצג על ידי opencode pair.",
"server.connect.camera": "מצלמת צימוד",
"server.connect.camera.starting": "פותח את המצלמה…",
"server.connect.mixedContent": "לא ניתן להתחבר לשרת HTTP זה מדף HTTPS. יש להשתמש בכתובת שרת HTTPS במקום זאת.",
"server.connect.camera.insecure": "סריקת QR דורשת פתיחת דף זה דרך HTTPS או ב-localhost.",
"server.connect.camera.unavailable": "אין מצלמה זמינה לדפדפן זה. יש להזין את פרטי החיבור ידנית.",
"server.connect.camera.error": "לא ניתן לפתוח את המצלמה. יש לאפשר גישה למצלמה או להזין את פרטי החיבור ידנית.",
"dialog.server.menu.remove": "הסרה",
+2
View File
@@ -797,6 +797,7 @@ export const dict = {
"server.connect.button": "जोड़ना",
"server.connect.address.invalid": "एक वैध HTTP या HTTPS सर्वर पता दर्ज करें।",
"server.connect.failed": "कनेक्ट नहीं हो सका। सर्वर पता और पासवर्ड जांचें, फिर पुनः प्रयास करें।",
"server.connect.pair.description": "अपना कनेक्शन विवरण प्राप्त करने के लिए इस कमांड को अपने कंप्यूटर पर चलाएँ।",
"server.connect.scan": "QR कोड स्कैन करें",
"server.connect.scan.description": "अपने कैमरे को ओपनकोड जोड़ी द्वारा दिखाए गए QR कोड पर इंगित करें।",
"server.connect.scan.invalid": "यह कोई OpenCode युग्मन कोड नहीं है. ओपनकोड जोड़ी द्वारा दिखाए गए कोड को स्कैन करें।",
@@ -804,6 +805,7 @@ export const dict = {
"server.connect.camera.starting": "कैमरा खुल रहा है...",
"server.connect.mixedContent":
"किसी HTTPS पेज से इस HTTP सर्वर से कनेक्ट नहीं किया जा सका। इसके बजाय HTTPS सर्वर पते का उपयोग करें।",
"server.connect.camera.insecure": "QR स्कैनिंग के लिए इस पृष्ठ को HTTPS या लोकलहोस्ट पर खोलने की आवश्यकता है।",
"server.connect.camera.unavailable":
"इस ब्राउज़र पर कोई कैमरा उपलब्ध नहीं है. अपना कनेक्शन विवरण मैन्युअल रूप से दर्ज करें।",
"server.connect.camera.error":
+3
View File
@@ -805,6 +805,7 @@ export const dict = {
"server.connect.button": "Poveži se",
"server.connect.address.invalid": "Unesite ispravnu HTTP ili HTTPS adresu poslužitelja.",
"server.connect.failed": "Povezivanje nije uspjelo. Provjerite adresu poslužitelja i lozinku, pa pokušajte ponovno.",
"server.connect.pair.description": "Pokrenite ovu naredbu na računalu da biste dobili podatke za povezivanje.",
"server.connect.scan": "Skeniraj QR kod",
"server.connect.scan.description": "Usmjerite kameru prema QR kodu koji prikazuje opencode pair.",
"server.connect.scan.invalid": "Ovo nije OpenCode kod za uparivanje. Skenirajte kod koji prikazuje opencode pair.",
@@ -812,6 +813,8 @@ export const dict = {
"server.connect.camera.starting": "Otvaranje kamere…",
"server.connect.mixedContent":
"Nije se moguće povezati s ovim HTTP poslužiteljem s HTTPS stranice. Umjesto toga koristite HTTPS adresu poslužitelja.",
"server.connect.camera.insecure":
"Za skeniranje QR koda ovu stranicu morate otvoriti putem HTTPS-a ili na localračunalu domaćinu.",
"server.connect.camera.unavailable": "Ovom pregledniku nije dostupna kamera. Ručno unesite podatke za povezivanje.",
"server.connect.camera.error":
"Kameru nije bilo moguće otvoriti. Dozvolite pristup kameri ili ručno unesite podatke za povezivanje.",
+3
View File
@@ -794,6 +794,8 @@ export const dict = {
"server.connect.button": "Csatlakozás",
"server.connect.address.invalid": "Adjon meg egy érvényes HTTP vagy HTTPS szervercímet.",
"server.connect.failed": "Nem sikerült csatlakozni. Ellenőrizze a szerver címét és jelszavát, majd próbálja újra.",
"server.connect.pair.description":
"Futtassa ezt a parancsot a számítógépén a kapcsolat részleteinek megtekintéséhez.",
"server.connect.scan": "QR-kód beolvasása",
"server.connect.scan.description": "Irányítsa kameráját a opencode pair által mutatott QR kódra.",
"server.connect.scan.invalid": "Ez nem egy OpenCode párosítási kód. Olvassa be a opencode pair által mutatott kódot.",
@@ -801,6 +803,7 @@ export const dict = {
"server.connect.camera.starting": "Kamera nyitása…",
"server.connect.mixedContent":
"Nem sikerült csatlakozni ehhez a HTTP szerverhez egy HTTPS oldalról. Használjon helyette HTTPS szervercímet.",
"server.connect.camera.insecure": "A QR beolvasásához ezt az oldalt a HTTPS vagy a localhost oldalon kell megnyitni.",
"server.connect.camera.unavailable":
"Ehhez a böngészőhöz nem érhető el kamera. Adja meg manuálisan a kapcsolat adatait.",
"server.connect.camera.error":
+2
View File
@@ -795,12 +795,14 @@ export const dict = {
"server.connect.button": "Կապվել",
"server.connect.address.invalid": "Մուտքագրեք վավեր HTTP կամ HTTPS սերվերի հասցե։",
"server.connect.failed": "Չկապվեց. Ստուգեք սերվերի հասցեն և գաղտնաբառը, ապա փորձեք նորից։",
"server.connect.pair.description": "Գործարկեք այս հրամանը ձեր համակարգչում՝ ստանալու ձեր կապի մանրամասները։",
"server.connect.scan": "Սկանավորեք QR կոդը",
"server.connect.scan.description": "Տեսախցիկն ուղղեք opencode pair-ի ցուցադրած QR կոդին։",
"server.connect.scan.invalid": "Սա OpenCode-ի զուգակցման կոդ չէ։ Սկանավորեք opencode pair-ի ցուցադրած կոդը։",
"server.connect.camera": "Զույգագրման վահանակը",
"server.connect.camera.starting": "Բացվում է վահանակը…",
"server.connect.mixedContent": "Այս HTTP սերվերին հնարավոր չէ միանալ HTTPS էջից։ Օգտագործեք HTTPS սերվերի հասցե։",
"server.connect.camera.insecure": "QR սկանումը պահանջում է այս էջը բացել HTTPS-ով կամ տեղական հոստում։",
"server.connect.camera.unavailable": "Այս զննարկչին վահանակ հասանելի չէ։ Մուտքագրեք ձեր կապի մանրամասները ձեռքով։",
"server.connect.camera.error":
"Չկարողացա բացել տեսախցիկը։ Թույլատրել տեսախցիկի մուտքը կամ ձեռքով ներմուծել ձեր կապի տվյալները։",
+2
View File
@@ -862,6 +862,7 @@ export const dict = {
"server.connect.button": "Sambungkan",
"server.connect.address.invalid": "Masukkan alamat server HTTP atau HTTPS yang valid.",
"server.connect.failed": "Tidak dapat terhubung. Periksa alamat server dan kata sandi, lalu coba lagi.",
"server.connect.pair.description": "Jalankan perintah ini di komputer Anda untuk mendapatkan detail koneksi Anda.",
"server.connect.scan": "Pindai kode QR",
"server.connect.scan.description": "Arahkan kamera Anda ke kode QR yang ditampilkan oleh opencode pair.",
"server.connect.scan.invalid": "Ini bukan kode pemasangan OpenCode. Pindai kode yang ditampilkan oleh opencode pair.",
@@ -869,6 +870,7 @@ export const dict = {
"server.connect.camera.starting": "Membuka kamera…",
"server.connect.mixedContent":
"Tidak dapat terhubung ke server HTTP ini dari halaman HTTPS. Gunakan alamat server HTTPS sebagai gantinya.",
"server.connect.camera.insecure": "Pemindaian QR memerlukan halaman ini dibuka melalui HTTPS atau di localhost.",
"server.connect.camera.unavailable":
"Tidak ada kamera yang tersedia untuk peramban ini. Masukkan detail koneksi Anda secara manual.",
"server.connect.camera.error":
+2
View File
@@ -782,6 +782,7 @@ export const dict = {
"server.connect.button": "Tengdu",
"server.connect.address.invalid": "Sláðu inn gilt HTTP eða HTTPS netfang netþjóns.",
"server.connect.failed": "Ekki tókst að tengjast. Athugaðu netfang netþjóns og lykilorð, reyndu svo aftur.",
"server.connect.pair.description": "Keyrðu þessa skipun á tölvunni þinni til að fá upplýsingar um tenginguna þína.",
"server.connect.scan": "Skanna QR kóða",
"server.connect.scan.description": "Beindu myndavélinni þinni að QR kóðanum sem opencode pair sýnir.",
"server.connect.scan.invalid": "Þetta er ekki OpenCode pörunarkóði. Skannaðu kóðann sem opencode pair sýnir.",
@@ -789,6 +790,7 @@ export const dict = {
"server.connect.camera.starting": "Opnar myndavél...",
"server.connect.mixedContent":
"Gat ekki tengst þessum HTTP netþjóni frá HTTPS síðu. Notaðu HTTPS netþjónsfang í staðinn.",
"server.connect.camera.insecure": "QR skönnun krefst þess að opna þessa síðu yfir HTTPS eða á localhost.",
"server.connect.camera.unavailable":
"Engin myndavél er í boði fyrir þennan vafra. Sláðu inn tengiupplýsingar þínar handvirkt.",
"server.connect.camera.error":
+3
View File
@@ -22,6 +22,8 @@ export const dict = {
"server.connect.button": "Connettiti",
"server.connect.address.invalid": "Inserisci un indirizzo server HTTP o HTTPS valido.",
"server.connect.failed": "Impossibile connettersi. Controlla l'indirizzo del server e la password, quindi riprova.",
"server.connect.pair.description":
"Esegui questo comando sul tuo computer per ottenere i dettagli della connessione.",
"server.connect.scan": "Scansiona il codice QR",
"server.connect.scan.description": "Punta la fotocamera sul codice QR mostrato da opencode pair.",
"server.connect.scan.invalid":
@@ -30,6 +32,7 @@ export const dict = {
"server.connect.camera.starting": "Apertura della fotocamera…",
"server.connect.mixedContent":
"Impossibile connettersi a questo server HTTP da una pagina HTTPS. Utilizza invece un indirizzo server HTTPS.",
"server.connect.camera.insecure": "La scansione QR richiede l'apertura di questa pagina su HTTPS o su localhost.",
"server.connect.camera.unavailable":
"Nessuna fotocamera è disponibile per questo browser. Inserisci manualmente i dettagli della tua connessione.",
"server.connect.camera.error":
+2
View File
@@ -808,6 +808,7 @@ export const dict = {
"server.connect.button": "接続",
"server.connect.address.invalid": "有効なHTTPまたはHTTPSサーバーアドレスを入力してください。",
"server.connect.failed": "接続できませんでした。サーバーアドレスとパスワードを確認して、もう一度お試しください。",
"server.connect.pair.description": "このコマンドをコンピューターで実行して接続情報を取得してください。",
"server.connect.scan": "QRコードをスキャン",
"server.connect.scan.description": "opencode pair に表示されたQRコードにカメラを向けてください。",
"server.connect.scan.invalid":
@@ -816,6 +817,7 @@ export const dict = {
"server.connect.camera.starting": "カメラを開いています…",
"server.connect.mixedContent":
"HTTPSページからこのHTTPサーバーに接続できません。代わりにHTTPSサーバーのアドレスを使用してください。",
"server.connect.camera.insecure": "QRスキャンには、このページをHTTPSで開くか、ローカルホストで開く必要があります。",
"server.connect.camera.unavailable": "このブラウザで使用可能なカメラがありません。接続情報を手動で入力してください。",
"server.connect.camera.error":
"カメラを開くことができません。カメラへのアクセスを許可するか、接続情報を手動で入力してください。",
+2
View File
@@ -791,6 +791,7 @@ export const dict = {
"server.connect.button": "დაკავშირება",
"server.connect.address.invalid": "შეიყვანეთ ვალიდური HTTP ან HTTPS სერვერის მისამართი.",
"server.connect.failed": "ვერ მოხერხდა დაკავშირება. შეამოწმეთ სერვერის მისამართი და პაროლი, შემდეგ კვლავ სცადეთ.",
"server.connect.pair.description": "ჩაატარეთ ეს ბრძანება თქვენს კომპიუტერზე, რათა მიიღოთ თქვენი კავშირის დეტალები.",
"server.connect.scan": "სკანირება QR კოდით",
"server.connect.scan.description": "მიმართეთ კამერა opencode pair-ის მიერ ნაჩვენებ QR კოდზე.",
"server.connect.scan.invalid":
@@ -799,6 +800,7 @@ export const dict = {
"server.connect.camera.starting": "კამერის გახსნა…",
"server.connect.mixedContent":
"ვერ მოხერხდა დაკავშირება ამ HTTP სერვერთან HTTPS გვერდიდან. გამოიყენეთ HTTPS სერვერის მისამართი.",
"server.connect.camera.insecure": "QR სკანირება საჭიროებს ამ გვერდის გახსნა HTTPS-ზე ან localhost-ზე.",
"server.connect.camera.unavailable":
"ამ ბრაუზერისთვის კამერა არ არის ხელმისაწვდომი. შეიყვანეთ თქვენი კავშირის დეტალები ხელით.",
"server.connect.camera.error":
+2
View File
@@ -791,6 +791,7 @@ export const dict = {
"server.connect.button": "ភ្ជាប់",
"server.connect.address.invalid": "បញ្ចូលអាសយដ្ឋានម៉ាស៊ីនមេ HTTP ឬ HTTPS ត្រឹមត្រូវ។",
"server.connect.failed": "មិនអាចភ្ជាប់បានទេ។ សូមពិនិត្យអាសយដ្ឋានម៉ាស៊ីនមេ និងពាក្យសម្ងាត់ រួចព្យាយាមម្តងទៀត។",
"server.connect.pair.description": "បើកកម្មង់នេះនៅលើកុំព្យូទ័ររបស់អ្នកដើម្បីទទួលព័ត៌មានការភ្ជាប់របស់អ្នក។",
"server.connect.scan": "ស្កេនកូដ QR",
"server.connect.scan.description": "បង្ខំកាមេរ៉ារបស់អ្នកទៅកូដ QR ដែលបង្ហាញដោយ opencode pair។",
"server.connect.scan.invalid": "នេះមិនមែនជាកូដផ្គុំ OpenCode ទេ។ ស្កេនកូដដែលបង្ហាញដោយ opencode pair។",
@@ -798,6 +799,7 @@ export const dict = {
"server.connect.camera.starting": "កំពុងបើកកាមេរ៉ា…",
"server.connect.mixedContent":
"មិនអាចភ្ជាប់ទៅម៉ាស៊ីនបម្រើ HTTP នេះពីទំព័រ HTTPS។ សូមប្រើអាសយដ្ឋានម៉ាស៊ីនបម្រើ HTTPS ជំនួស។",
"server.connect.camera.insecure": "ការស្កេន QR ត្រូវការបើកទំព័រនេះតាម HTTPS ឬនៅលើ localhost។",
"server.connect.camera.unavailable":
"មិនមានកាមេរ៉ាដើម្បីប្រើប្រាស់ជាមួយកម្មវិធីនេះទេ។ សូមបញ្ចូលព័ត៌មានការភ្ជាប់ដោយដៃ។",
"server.connect.camera.error": "មិនអាចបើកកាមេរ៉ាបាន។ អនុញ្ញាតឱ្យចូលដំណើរការកាមេរ៉ា ឬបញ្ចូលព័ត៌មានការភ្ជាប់ដោយដៃ។",
+2
View File
@@ -803,6 +803,7 @@ export const dict = {
"server.connect.button": "연결",
"server.connect.address.invalid": "유효한 HTTP 또는 HTTPS 서버 주소를 입력하세요.",
"server.connect.failed": "연결할 수 없습니다. 서버 주소와 비밀번호를 확인한 후 다시 시도하세요.",
"server.connect.pair.description": "컴퓨터에서 이 명령어를 실행하여 연결 세부 정보를 확인하세요.",
"server.connect.scan": "QR 코드 스캔",
"server.connect.scan.description": "opencode pair에서 표시된 QR 코드를 카메라로 가리키세요.",
"server.connect.scan.invalid":
@@ -811,6 +812,7 @@ export const dict = {
"server.connect.camera.starting": "카메라 열기…",
"server.connect.mixedContent":
"HTTPS 페이지에서 이 HTTP 서버에 연결할 수 없습니다. 대신 HTTPS 서버 주소를 사용하세요.",
"server.connect.camera.insecure": "QR 스캔을 위해서는 HTTPS를 통해 이 페이지를 열거나 로컬호스트에서 열어야 합니다.",
"server.connect.camera.unavailable":
"이 브라우저에서 사용할 수 있는 카메라가 없습니다. 연결 세부 정보를 수동으로 입력하세요.",
"server.connect.camera.error":
+2
View File
@@ -790,12 +790,14 @@ export const dict = {
"server.connect.button": "ເຊື່ອມຕໍ່",
"server.connect.address.invalid": "ໃສ່ທີ່ຢູ່ເຊີບເວີ HTTP ຫຼື HTTPS ທີ່ຖືກຕ້ອງ.",
"server.connect.failed": "ບໍ່ສາມາດເຊື່ອມຕໍ່ໄດ້.ກວດເບິ່ງທີ່ຢູ່ເຊີບເວີແລະລະຫັດຜ່ານ, ແລ້ວລອງອີກຄັ້ງ.",
"server.connect.pair.description": "ເຮັດຄໍາສັ່ງນີ້ໃນຄອມພິວເຕີຂອງທ່ານເພື່ອຮັບລາຍລະອຽດການເຊື່ອມຕໍ່.",
"server.connect.scan": "ສະແກນ QR code",
"server.connect.scan.description": "ຊີ້ກ້ອງຂອງທ່ານໄປທີ່ QR code ທີ່ສະແດງໂດຍຄູ່ opencode.",
"server.connect.scan.invalid": "ນີ້ບໍ່ແມ່ນລະຫັດການຈັດຄູ່ OpenCode.ສະແກນລະຫັດທີ່ສະແດງໂດຍຄູ່ OpenCode.",
"server.connect.camera": "ການຈັບຄູ່ກ້ອງ",
"server.connect.camera.starting": "ກໍາລັງເປີດກ້ອງ...",
"server.connect.mixedContent": "ບໍ່ສາມາດເຊື່ອມຕໍ່ກັບເຊີບເວີ HTTP ນີ້ຈາກຫນ້າ HTTPS ໄດ້.ໃຊ້ທີ່ຢູ່ເຊີບເວີ HTTPS ແທນ.",
"server.connect.camera.insecure": "ການສະແກນ QR ຈໍາເປັນຕ້ອງເປີດຫນ້ານີ້ຜ່ານ HTTPS ຫຼືໃນ localhost.",
"server.connect.camera.unavailable": "ບໍ່ມີກ້ອງຖ່າຍຮູບໃຫ້ໃຊ້ໃນເບຣາວເຊີນີ້.ໃສ່ລາຍລະອຽດການເຊື່ອມຕໍ່ຂອງທ່ານເອງ.",
"server.connect.camera.error": "ບໍ່ສາມາດເປີດກ້ອງໄດ້.ອະນຸຍາດໃຫ້ເຂົ້າເຖິງກ້ອງ ຫຼື ປ້ອນລາຍລະອຽດການເຊື່ອມຕໍ່ຂອງທ່ານເອງ.",
"dialog.server.menu.remove": "ລຶບ",
+3
View File
@@ -805,6 +805,8 @@ export const dict = {
"server.connect.address.invalid": "Įveskite galiojantį HTTP arba HTTPS serverio adresą.",
"server.connect.failed":
"Nepavyko prisijungti. Patikrinkite serverio adresą ir slaptažodį, tada bandykite dar kartą.",
"server.connect.pair.description":
"Paleiskite šią komandą savo kompiuteryje, kad gautumėte išsamią ryšio informaciją.",
"server.connect.scan": "Nuskaitykite QR kodą",
"server.connect.scan.description": "Nukreipkite fotoaparatą į QR kodą, rodomą opencode pair.",
"server.connect.scan.invalid": "Tai nėra OpenCode susiejimo kodas. Nuskaitykite kodą, kurį rodo opencode pair.",
@@ -812,6 +814,7 @@ export const dict = {
"server.connect.camera.starting": "Atidaroma kamera…",
"server.connect.mixedContent":
"Nepavyko prisijungti prie šio HTTP serverio iš HTTPS puslapio. Vietoj to naudokite HTTPS serverio adresą.",
"server.connect.camera.insecure": "Norint nuskaityti QR, reikia atidaryti šį puslapį per HTTPS arba localhost.",
"server.connect.camera.unavailable": "Šioje naršyklėje nėra fotoaparato. Įveskite savo ryšio duomenis rankiniu būdu.",
"server.connect.camera.error":
"Nepavyko atidaryti fotoaparato. Suteikite prieigą prie fotoaparato arba įveskite savo ryšio duomenis rankiniu būdu.",
+2
View File
@@ -792,6 +792,7 @@ export const dict = {
"server.connect.address.invalid": "Ievadiet derīgu HTTP vai HTTPS servera adresi.",
"server.connect.failed":
"Nevarēja izveidot savienojumu. Pārbaudiet servera adresi un paroli, pēc tam mēģiniet vēlreiz.",
"server.connect.pair.description": "Palaidiet šo komandu datorā, lai iegūtu informāciju par savienojumu.",
"server.connect.scan": "Skenējiet QR kodu",
"server.connect.scan.description": "Pavērsiet kameru pret QR kodu, ko parāda opencode pair.",
"server.connect.scan.invalid": "Šis nav kods OpenCode savienošanai pārī. Skenējiet kodu, ko parāda opencode pair.",
@@ -799,6 +800,7 @@ export const dict = {
"server.connect.camera.starting": "Tiek atvērta kamera…",
"server.connect.mixedContent":
"Nevarēja izveidot savienojumu ar šo HTTP serveri no HTTPS lapas. Tā vietā izmantojiet HTTPS servera adresi.",
"server.connect.camera.insecure": "Lai skenētu QR, šī lapa ir jāatver, izmantojot HTTPS vai localhost.",
"server.connect.camera.unavailable":
"Šai pārlūkprogrammai nav pieejama neviena kamera. Manuāli ievadiet savienojuma informāciju.",
"server.connect.camera.error":
+3
View File
@@ -795,6 +795,8 @@ export const dict = {
"server.connect.address.invalid": "Внесете валидна HTTP или HTTPS адреса на серверот.",
"server.connect.failed":
"Не можеше да се поврзе. Проверете ја адресата на серверот и лозинката, потоа обидете се повторно.",
"server.connect.pair.description":
"Извршете ја оваа команда на вашиот компјутер за да ги добиете деталите за вашата конекција.",
"server.connect.scan": "Скенирај QR код",
"server.connect.scan.description": "Насочете ја вашата камера кон QR кодот прикажан од opencode pair.",
"server.connect.scan.invalid": "Ова не е OpenCode паринг код. Скенирајте го кодот прикажан од opencode pair.",
@@ -802,6 +804,7 @@ export const dict = {
"server.connect.camera.starting": "Отворање на камерата…",
"server.connect.mixedContent":
"Не можевте да се поврзете на овој HTTP сервер од HTTPS страница. Користете HTTPS сервер адреса наместо тоа.",
"server.connect.camera.insecure": "Скенирањето на QR бара отворање на оваа страница преку HTTPS или на localhost.",
"server.connect.camera.unavailable":
"Нема камера достапна за овој прелистувач. Внесете ги деталите за вашата конекција рачно.",
"server.connect.camera.error":
+3
View File
@@ -797,6 +797,8 @@ export const dict = {
"server.connect.button": "Холбох",
"server.connect.address.invalid": "Хүчинтэй HTTP эсвэл HTTPS серверийн хаягийг оруулна уу.",
"server.connect.failed": "Холбогдож чадсангүй. Серверийн хаяг ба нууц үгийг шалгаад дахин оролдоно уу.",
"server.connect.pair.description":
"Энэ командыг өөрийн компьютер дээр ажиллуулж, холболтын дэлгэрэнгүй мэдээллийг аваарай.",
"server.connect.scan": "QR кодыг сканнердах",
"server.connect.scan.description": "Камераа opencode pair-ийн харуулсан QR код руу чиглүүлнэ үү.",
"server.connect.scan.invalid": "Энэ OpenCode холболтын код биш. opencode pair-ийн харуулсан кодыг уншуулна уу.",
@@ -804,6 +806,7 @@ export const dict = {
"server.connect.camera.starting": "Камер нээж байна…",
"server.connect.mixedContent":
"HTTPS хуудсаас энэ HTTP серверт холбогдож чадсангүй. Үүний оронд HTTPS серверийн хаягийг ашигла.",
"server.connect.camera.insecure": "QR сканнердах нь энэ хуудасыг HTTPS дээр эсвэл localhost дээр нээх шаардлагатай.",
"server.connect.camera.unavailable": "Энэ хөтчид камер байхгүй байна. Холболтын мэдээллээ гар аргаар оруулна уу.",
"server.connect.camera.error":
"Камерыг нээж чадсангүй. Камер ашиглахыг зөвшөөрөх эсвэл холболтын дэлгэрэнгүй мэдээллээ гараар оруулна уу.",
+2
View File
@@ -795,6 +795,7 @@ export const dict = {
"server.connect.button": "Sambung",
"server.connect.address.invalid": "Masukkan alamat pelayan HTTP atau HTTPS yang sah.",
"server.connect.failed": "Tidak dapat menyambung. Semak alamat pelayan dan kata laluan, kemudian cuba lagi.",
"server.connect.pair.description": "Jalankan arahan ini pada komputer anda untuk mendapatkan butiran sambungan anda.",
"server.connect.scan": "Imbas kod QR",
"server.connect.scan.description": "Tujukan kamera anda ke kod QR yang dipaparkan oleh pasangan opencode.",
"server.connect.scan.invalid": "Ini bukan kod pasangan OpenCode. Imbas kod yang ditunjukkan oleh pasangan opencode.",
@@ -802,6 +803,7 @@ export const dict = {
"server.connect.camera.starting": "Membuka kamera…",
"server.connect.mixedContent":
"Tidak dapat sambung ke pelayan HTTP ini dari halaman HTTPS. Gunakan alamat pelayan HTTPS sebaliknya.",
"server.connect.camera.insecure": "Imbasan QR memerlukan pembukaan halaman ini melalui HTTPS atau di localhost.",
"server.connect.camera.unavailable":
"Tiada kamera tersedia untuk pelayar ini. Masukkan butiran sambungan anda secara manual.",
"server.connect.camera.error":
+3
View File
@@ -808,6 +808,7 @@ export const dict = {
"server.connect.button": "ချိတ်ဆက်မည်",
"server.connect.address.invalid": "မှန်ကန်သော HTTP သို့မဟုတ် HTTPS server လိပ်စာကို ထည့်ပါ။",
"server.connect.failed": "ချိတ်ဆက်၍ မရပါ။ Server လိပ်စာနှင့် စကားဝှက်ကို စစ်ဆေးပြီး ပြန်ကြိုးစားပါ။",
"server.connect.pair.description": "ချိတ်ဆက်ရန်အသေးစိတ်ကို ရယူရန် သင့်ကွန်ပျူတာတွင် ဤ command ကို chạy ဆောင်ရန်။",
"server.connect.scan": "QR ကုဒ် စကင်",
"server.connect.scan.description": "opencode pair မှ ပြသထားသော QR ကုဒ်ကို သင်၏ ကင်မရာဖြင့် ညွှန်ပါ။",
"server.connect.scan.invalid":
@@ -815,6 +816,8 @@ export const dict = {
"server.connect.camera": "ကင်မရာကို အစုံပေါင်းခြင်း",
"server.connect.camera.starting": "ကင်မရာဖွင့်နေသည်…",
"server.connect.mixedContent": "HTTPS စာမျက်နှာမှ ဤ HTTP ဆာဗာထံ ချိတ်ဆက်၍ မရနိုင်ပါ။ HTTPS ဆာဗာလိပ်စာကို အသုံးပြုပါ။",
"server.connect.camera.insecure":
"QR စကင်လုပ်ရန် HTTPS ဖြင့် သို့မဟုတ် localhost တွင် ဤစာမျက်နှာကို ဖွင့်ရန် လိုအပ်သည်။",
"server.connect.camera.unavailable": "ဤဘရောက်ဇာတွင် ကင်မရာမရှိပါ။ ချိတ်ဆက်မှုအချက်အလက်များကို လက်ဖြင့်ထည့်ပါ။",
"server.connect.camera.error":
"ကင်မရာဖွင့်၍ မရနိုင်ပါ။ ကင်မရာခွင့်ပြုချက်ပေးပါ သို့မဟုတ် ချိတ်ဆက်မှုအချက်အလက်များကို လက်ဖြင့်ထည့်ပါ။",
+2
View File
@@ -797,6 +797,7 @@ export const dict = {
"server.connect.button": "जडान गर्नुहोस्",
"server.connect.address.invalid": "मान्य HTTP वा HTTPS सर्भर ठेगाना प्रविष्ट गर्नुहोस्।",
"server.connect.failed": "जडान हुन सकेन। सर्भर ठेगाना र पासवर्ड जाँच गर्नुहोस्, त्यसपछि फेरि प्रयास गर्नुहोस्।",
"server.connect.pair.description": "तपाइँको जडान विवरण प्राप्त गर्न तपाइँको कम्प्युटरमा यो आदेश चलाउनुहोस्।",
"server.connect.scan": "QR कोड स्क्यान गर्नुहोस्",
"server.connect.scan.description": "ओपनकोड जोडीद्वारा देखाइएको QR कोडमा आफ्नो क्यामेरा देखाउनुहोस्।",
"server.connect.scan.invalid": "यो एक OpenCode जोडा कोड होइन। ओपनकोड जोडी द्वारा देखाइएको कोड स्क्यान गर्नुहोस्।",
@@ -804,6 +805,7 @@ export const dict = {
"server.connect.camera.starting": "क्यामेरा खोल्दै…",
"server.connect.mixedContent":
"HTTPS पृष्ठबाट यो HTTP सर्भरमा जडान गर्न सकिएन। यसको सट्टा HTTPS सर्भर ठेगाना प्रयोग गर्नुहोस्।",
"server.connect.camera.insecure": "QR स्क्यानिङका लागि यो पृष्ठ HTTPS वा लोकलहोस्टमा खोल्न आवश्यक छ।",
"server.connect.camera.unavailable":
"यो ब्राउजरमा कुनै क्यामेरा उपलब्ध छैन। आफ्नो जडान विवरणहरू म्यानुअल रूपमा प्रविष्ट गर्नुहोस्।",
"server.connect.camera.error":
+3
View File
@@ -23,6 +23,7 @@ export const dict = {
"server.connect.address.invalid": "Voer een geldig HTTP- of HTTPS-serveradres in.",
"server.connect.failed":
"Kan geen verbinding maken. Controleer het serveradres en het wachtwoord en probeer het opnieuw.",
"server.connect.pair.description": "Voer deze opdracht uit op uw computer om uw verbindingsgegevens op te halen.",
"server.connect.scan": "Scan de QR-code",
"server.connect.scan.description": "Richt uw camera op de QR-code weergegeven door opencode pair.",
"server.connect.scan.invalid": "Dit is geen OpenCode-koppelingscode. Scan de code weergegeven door opencode pair.",
@@ -30,6 +31,8 @@ export const dict = {
"server.connect.camera.starting": "Camera openen…",
"server.connect.mixedContent":
"Kan geen verbinding maken met deze HTTP-server vanaf een HTTPS-pagina. Gebruik in plaats daarvan een HTTPS-serveradres.",
"server.connect.camera.insecure":
"Voor het scannen van QR moet deze pagina worden geopend via HTTPS of op localhost.",
"server.connect.camera.unavailable":
"Er is geen camera beschikbaar voor deze browser. Voer uw verbindingsgegevens handmatig in.",
"server.connect.camera.error":
+2
View File
@@ -26,6 +26,7 @@ export const dict = {
"server.connect.button": "Koble til",
"server.connect.address.invalid": "Skriv inn en gyldig HTTP- eller HTTPS-serveradresse.",
"server.connect.failed": "Kunne ikke koble til. Sjekk serveradressen og passordet, og prøv på nytt.",
"server.connect.pair.description": "Kjør denne kommandoen på datamaskinen for å få tilkoblingsdetaljer.",
"server.connect.scan": "Skann QR-koden",
"server.connect.scan.description": "Pek kameraet mot QR-koden vist av opencode pair.",
"server.connect.scan.invalid": "Dette er ikke en OpenCode-paringskode. Skann koden vist av opencode pair.",
@@ -33,6 +34,7 @@ export const dict = {
"server.connect.camera.starting": "Åpner kamera …",
"server.connect.mixedContent":
"Kunne ikke koble til denne HTTP-serveren fra en HTTPS-side. Bruk en HTTPS-serveradresse i stedet.",
"server.connect.camera.insecure": "QR-skanning krever at denne siden åpnes over HTTPS eller på localhost.",
"server.connect.camera.unavailable":
"Ingen kamera er tilgjengelig for denne nettleseren. Skriv inn tilkoblingsdetaljene manuelt.",
"server.connect.camera.error": "Kunne ikke åpne kameraet. Gi kameratilgang eller angi tilkoblingsdetaljene manuelt.",
+2
View File
@@ -803,6 +803,7 @@ export const dict = {
"server.connect.button": "جڑیں۔",
"server.connect.address.invalid": "ایک درست HTTP یا HTTPS سرور دا پتہ درج کرو۔",
"server.connect.failed": "رابطہ نئیں ہو سکا۔ سرور دا پتہ اور پاس ورڈ چیک کرو، پھر دوبارہ کوشش کرو۔",
"server.connect.pair.description": "اپنے کنکشن دی تفصیلات حاصل کرنے لئی اس کمانڈ کو اپنے کمپیوٹر پر چلائیں۔",
"server.connect.scan": "QR کوڈ اسکین کرو۔",
"server.connect.scan.description": "اپنے کیمرے کو اوپن کوڈ پیئر دے ذریعے دکھائے گئے QR کوڈ دی طرف پوائنٹ کرو۔",
"server.connect.scan.invalid":
@@ -811,6 +812,7 @@ export const dict = {
"server.connect.camera.starting": "کیمرہ کھل رہا ہے…",
"server.connect.mixedContent":
"ایک HTTPS صفحہ سے اس HTTP سرور سے مربوط نئیں ہو سکا۔ اس دے بجائے ایک HTTPS سرور دا پتہ استعمال کرو۔",
"server.connect.camera.insecure": "QR اسکیننگ لئی اس صفحہ کو HTTPS پر یا لوکل ہوسٹ پر کھولنے دی ضرورت اے۔",
"server.connect.camera.unavailable":
"اس براؤزر پر کوئی کیمرہ دستیاب نئیں اے۔ اپنے کنکشن دی تفصیلات دستی طور پر درج کرو۔",
"server.connect.camera.error":
+2
View File
@@ -820,6 +820,7 @@ export const dict = {
"server.connect.button": "Połącz",
"server.connect.address.invalid": "Wprowadź prawidłowy adres serwera HTTP lub HTTPS.",
"server.connect.failed": "Nie można się połączyć. Sprawdź adres serwera i hasło, a następnie spróbuj ponownie.",
"server.connect.pair.description": "Uruchom to polecenie na swoim komputerze, aby uzyskać szczegóły połączenia.",
"server.connect.scan": "Zeskanuj kod QR",
"server.connect.scan.description": "Skieruj kamerę na kod QR pokazany przez sparowanie opencode.",
"server.connect.scan.invalid": "To nie jest kod parowania OpenCode. Zeskanuj kod pokazany przez sparowanie opencode.",
@@ -827,6 +828,7 @@ export const dict = {
"server.connect.camera.starting": "Otwieranie kamery…",
"server.connect.mixedContent":
"Nie można połączyć się z tym serwerem HTTP z strony HTTPS. Zamiast tego użyj adresu serwera HTTPS.",
"server.connect.camera.insecure": "Skanowanie QR wymaga otwarcia tej strony przez HTTPS lub na localhost.",
"server.connect.camera.unavailable": "Ta przeglądarka nie ma dostępu do kamery. Wprowadź dane połączenia ręcznie.",
"server.connect.camera.error":
"Nie udało się otworzyć kamery. Zezwól na dostęp do kamery lub wprowadź dane połączenia ręcznie.",
+2
View File
@@ -790,6 +790,7 @@ export const dict = {
"server.connect.button": "Conectați",
"server.connect.address.invalid": "Introduceți o adresă validă de server HTTP sau HTTPS.",
"server.connect.failed": "Nu s-a putut conecta. Verificați adresa serverului și parola, apoi încercați din nou.",
"server.connect.pair.description": "Rulați această comandă pe computer pentru a obține detaliile conexiunii.",
"server.connect.scan": "Scanează codul QR",
"server.connect.scan.description": "Îndreptați camera către codul QR afișat de opencode pair.",
"server.connect.scan.invalid": "Acesta nu este un cod de asociere OpenCode. Scanați codul afișat de opencode pair.",
@@ -797,6 +798,7 @@ export const dict = {
"server.connect.camera.starting": "Deschiderea camerei…",
"server.connect.mixedContent":
"Nu s-a putut conecta la acest server HTTP de pe o pagină HTTPS. Utilizați în schimb o adresă de server HTTPS.",
"server.connect.camera.insecure": "Scanarea QR necesită deschiderea acestei pagini prin HTTPS sau pe localhost.",
"server.connect.camera.unavailable":
"Nicio cameră nu este disponibilă pentru acest browser. Introduceți manual detaliile conexiunii.",
"server.connect.camera.error":
+3
View File
@@ -876,6 +876,7 @@ export const dict = {
"server.connect.button": "Подключиться",
"server.connect.address.invalid": "Введите действительный адрес сервера HTTP или HTTPS.",
"server.connect.failed": "Не удалось подключиться. Проверьте адрес сервера и пароль, затем попробуйте снова.",
"server.connect.pair.description": "Выполните эту команду на вашем компьютере, чтобы получить данные подключения.",
"server.connect.scan": "Сканировать QR-код",
"server.connect.scan.description": "Наведите камеру на QR-код, показанный парой opencode.",
"server.connect.scan.invalid": "Это не код пары OpenCode. Сканируйте код, показанный парой opencode.",
@@ -883,6 +884,8 @@ export const dict = {
"server.connect.camera.starting": "Открытие камеры…",
"server.connect.mixedContent":
"Не удалось подключиться к этому HTTP-серверу с HTTPS-страницы. Используйте адрес HTTPS-сервера вместо этого.",
"server.connect.camera.insecure":
"Сканирование QR требует открытия этой страницы через HTTPS или на локальном хосте.",
"server.connect.camera.unavailable": "Камера недоступна для этого браузера. Введите данные подключения вручную.",
"server.connect.camera.error":
"Не удалось открыть камеру. Разрешите доступ к камере или введите данные подключения вручную.",
+2
View File
@@ -792,6 +792,7 @@ export const dict = {
"server.connect.button": "සම්බන්ධ කරන්න",
"server.connect.address.invalid": "වලංගු HTTP හෝ HTTPS සේවාදායක ලිපිනයක් ඇතුළත් කරන්න.",
"server.connect.failed": "සම්බන්ධ වීමට නොහැකි විය. සේවාදායක ලිපිනය සහ මුරපදය පරීක්ෂා කර නැවත උත්සාහ කරන්න.",
"server.connect.pair.description": "ඔබගේ සම්බන්ධතා විස්තර ලබා ගැනීමට මෙම විධානය ඔබගේ පරිගණකයේ ක්‍රියාත්මක කරන්න.",
"server.connect.scan": "QR කේතය පරිලෝකනය කරන්න",
"server.connect.scan.description": "opencode pair මගින් පෙන්වන QR කේතය වෙත ඔබේ කැමරාව යොමු කරන්න.",
"server.connect.scan.invalid":
@@ -800,6 +801,7 @@ export const dict = {
"server.connect.camera.starting": "කැමරාව විවෘත කරමින්...",
"server.connect.mixedContent":
"මෙම HTTP සේවාදායකයට HTTPS පිටුවකින් සම්බන්ධ වීමට නොහැකි විය. ඒ වෙනුවට HTTPS සේවාදායක ලිපිනයක් භාවිතා කරන්න.",
"server.connect.camera.insecure": "QR ස්කෑන් කිරීම සඳහා මෙම පිටුව HTTPS හරහා හෝ localhost මත විවෘත කිරීම අවශ්‍ය වේ.",
"server.connect.camera.unavailable": "මෙම බ්‍රවුසරයට කැමරාවක් නොමැත. ඔබගේ සම්බන්ධතා විස්තර අතින් ඇතුලත් කරන්න.",
"server.connect.camera.error":
"කැමරාව විවෘත කිරීමට නොහැකි විය. කැමරා ප්‍රවේශයට ඉඩ දෙන්න හෝ ඔබගේ සම්බන්ධතා විස්තර හස්තීයව ඇතුලත් කරන්න.",
+2
View File
@@ -815,6 +815,7 @@ export const dict = {
"server.connect.button": "Pripojiť sa",
"server.connect.address.invalid": "Zadajte platnú adresu servera s protokolom HTTP alebo HTTPS.",
"server.connect.failed": "Nepodarilo sa pripojiť. Skontrolujte adresu servera a heslo a skúste znova.",
"server.connect.pair.description": "Spustite tento príkaz na svojom počítači, aby ste získali údaje na pripojenie.",
"server.connect.scan": "Naskenujte QR kód",
"server.connect.scan.description": "Namierte kameru na QR kód zobrazený pomocou opencode pair.",
"server.connect.scan.invalid": "Toto nie je párovací kód OpenCode. Naskenujte kód zobrazený pomocou opencode pair.",
@@ -822,6 +823,7 @@ export const dict = {
"server.connect.camera.starting": "Otvorenie kamery…",
"server.connect.mixedContent":
"Nie je možné pripojiť sa k tomuto HTTP serveru z HTTPS stránky. Namiesto toho použite adresu HTTPS.",
"server.connect.camera.insecure": "Na skenovanie QR je potrebné otvoriť túto stránku cez HTTPS alebo na localhoste.",
"server.connect.camera.unavailable":
"Tento prehliadač nemá k dispozícii žiadnu kameru. Zadajte údaje na pripojenie ručne.",
"server.connect.camera.error":
+2
View File
@@ -815,6 +815,7 @@ export const dict = {
"server.connect.address.invalid": "Vnesite veljaven HTTP ali HTTPS naslov strežnika.",
"server.connect.failed":
"Povezave ni bilo mogoče vzpostaviti. Preverite naslov strežnika in geslo ter poskusite znova.",
"server.connect.pair.description": "Za pridobitev podatkov o povezavi zaženite ta ukaz na svojem računalniku.",
"server.connect.scan": "Skeniraj QR kodo",
"server.connect.scan.description": "Usmerite kamero na QR kodo, ki jo prikaže opencode pair.",
"server.connect.scan.invalid": "To ni OpenCode paritna koda. Skenirajte kodo, ki jo prikaže opencode pair.",
@@ -822,6 +823,7 @@ export const dict = {
"server.connect.camera.starting": "Odpiranje kamere…",
"server.connect.mixedContent":
"Z HTTP strežnikom se ni mogoče povezati iz HTTPS strani. Uporabite HTTPS naslov strežnika.",
"server.connect.camera.insecure": "Skeniranje QR zahteva odpiranje te strani preko HTTPS ali na localhost.",
"server.connect.camera.unavailable": "Brskalnik nima na voljo kamere. Vnesite podatke o povezavi ročno.",
"server.connect.camera.error":
"Kamer ne morem odpreti. Dovolite dostop do kamere ali vnesite podatke o povezavi ročno.",
+2
View File
@@ -782,6 +782,7 @@ export const dict = {
"server.connect.address.invalid": "Fut një adresë të vlefshme serveri HTTP ose HTTPS.",
"server.connect.failed":
"Nuk mund të lidhej. Kontrolloni adresën dhe fjalëkalimin e serverit, më pas provoni përsëri.",
"server.connect.pair.description": "Ekzekutoni këtë komandë në kompjuterin tuaj për të marrë detajet e lidhjes suaj.",
"server.connect.scan": "Skanoni kodin QR",
"server.connect.scan.description": "Drejtoje kamerën drejt kodit QR të treguar nga opencode pair.",
"server.connect.scan.invalid": "Ky nuk është një kod çiftimi OpenCode. Skanoni kodin e treguar nga opencode pair.",
@@ -789,6 +790,7 @@ export const dict = {
"server.connect.camera.starting": "Hapja e kamerës…",
"server.connect.mixedContent":
"Nuk mund të lidhej me këtë server HTTP nga një faqe HTTPS. Në vend të kësaj, përdorni një adresë serveri HTTPS.",
"server.connect.camera.insecure": "Skanimi QR kërkon hapjen e kësaj faqeje mbi HTTPS ose në localhost.",
"server.connect.camera.unavailable":
"Asnjë kamerë nuk disponohet për këtë shfletues. Futni manualisht detajet e lidhjes suaj.",
"server.connect.camera.error":
+2
View File
@@ -803,6 +803,7 @@ export const dict = {
"server.connect.button": "Повежи се",
"server.connect.address.invalid": "Унесите важећу HTTP или HTTPS адресу сервера.",
"server.connect.failed": "Није могуће повезати се. Проверите адресу сервера и лозинку, па покушајте поново.",
"server.connect.pair.description": "Покрените ову команду на рачунару да бисте добили податке за повезивање.",
"server.connect.scan": "Скенирај QR код",
"server.connect.scan.description": "Усмерите камеру на QR код који приказује opencode pair.",
"server.connect.scan.invalid": "Ово није OpenCode код за повезивање. Скенирајте код који приказује opencode pair.",
@@ -810,6 +811,7 @@ export const dict = {
"server.connect.camera.starting": "Отварање камере…",
"server.connect.mixedContent":
"Није могуће повезати се на овај HTTP сервер са HTTPS странице. Уместо тога користите HTTPS адресу сервера.",
"server.connect.camera.insecure": "Скенирање QR кода захтева отварање ове странице преко HTTPS-а или на лоцалхосту.",
"server.connect.camera.unavailable": "Овај прегледник нема доступну камеру. Унесите податке за повезивање ручно.",
"server.connect.camera.error":
"Није могуће отворити камеру. Дозволите приступ камери или унесите податке за повезивање ручно.",
+2
View File
@@ -22,6 +22,7 @@ export const dict = {
"server.connect.button": "Anslut",
"server.connect.address.invalid": "Ange en giltig HTTP- eller HTTPS-serveradress.",
"server.connect.failed": "Kunde inte ansluta. Kontrollera serveradressen och lösenordet och försök sedan igen.",
"server.connect.pair.description": "Kör det här kommandot på din dator för att få din anslutningsinformation.",
"server.connect.scan": "Skanna QR-koden",
"server.connect.scan.description": "Rikta kameran mot QR-koden som visas av opencode pair.",
"server.connect.scan.invalid": "Detta är inte en OpenCode parningskod. Skanna koden som visas av opencode pair.",
@@ -29,6 +30,7 @@ export const dict = {
"server.connect.camera.starting": "Öppnar kameran...",
"server.connect.mixedContent":
"Det gick inte att ansluta till denna HTTP-server från en HTTPS-sida. Använd en HTTPS-serveradress istället.",
"server.connect.camera.insecure": "QR-skanning kräver att den här sidan öppnas över HTTPS eller på localhost.",
"server.connect.camera.unavailable":
"Ingen kamera är tillgänglig för den här webbläsaren. Ange dina anslutningsuppgifter manuellt.",
"server.connect.camera.error":
+3
View File
@@ -796,6 +796,7 @@ export const dict = {
"server.connect.button": "Пайваст кунед",
"server.connect.address.invalid": "Суроғаи сервери дурусти HTTP ё HTTPS-ро ворид кунед.",
"server.connect.failed": "Пайваст шуда натавонист. Суроғаи сервер ва паролро тафтиш кунед ва аз нав кӯшиш кунед.",
"server.connect.pair.description": "Барои гирифтани тафсилоти пайвасти худ ин фармонро дар компютери худ иҷро кунед.",
"server.connect.scan": "Рамзи QR-ро скан кунед",
"server.connect.scan.description": "Камераро ба рамзи QR, ки opencode pair нишон медиҳад, равона кунед.",
"server.connect.scan.invalid":
@@ -804,6 +805,8 @@ export const dict = {
"server.connect.camera.starting": "Кушодани камера…",
"server.connect.mixedContent":
"Аз саҳифаи HTTPS ба ин сервери HTTP пайваст шуда натавонист. Ба ҷои он суроғаи сервери HTTPS-ро истифода баред.",
"server.connect.camera.insecure":
"Сканкунии QR талаб мекунад, ки ин саҳифа тавассути HTTPS ё дар localhost кушода шавад.",
"server.connect.camera.unavailable":
"Барои ин браузер ягон камера дастрас нест. Тафсилоти пайвасти худро дастӣ ворид кунед.",
"server.connect.camera.error":
+2
View File
@@ -852,12 +852,14 @@ export const dict = {
"server.connect.button": "เชื่อมต่อ",
"server.connect.address.invalid": "ใส่ที่อยู่เซิร์ฟเวอร์ HTTP หรือ HTTPS ที่ถูกต้อง",
"server.connect.failed": "ไม่สามารถเชื่อมต่อได้ ตรวจสอบที่อยู่เซิร์ฟเวอร์และรหัสผ่าน จากนั้นลองใหม่อีกครั้ง",
"server.connect.pair.description": "รันคำสั่งนี้บนคอมพิวเตอร์ของคุณเพื่อรับรายละเอียดการเชื่อมต่อของคุณ",
"server.connect.scan": "สแกนรหัส QR",
"server.connect.scan.description": "ชี้กล้องของคุณไปที่รหัส QR ที่แสดงโดยการจับคู่ opencode",
"server.connect.scan.invalid": "นี่ไม่ใช่รหัสจับคู่ OpenCode สแกนรหัสที่แสดงโดย opencode pair",
"server.connect.camera": "กำลังจับคู่กล้อง",
"server.connect.camera.starting": "กำลังเปิดกล้อง…",
"server.connect.mixedContent": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ HTTP จากหน้า HTTPS ใช้ที่อยู่เซิร์ฟเวอร์ HTTPS แทน",
"server.connect.camera.insecure": "การสแกน QR ต้องเปิดหน้านี้ผ่าน HTTPS หรือบน localhost",
"server.connect.camera.unavailable": "เบราว์เซอร์นี้ไม่สามารถใช้งานกล้องได้ กรอกรายละเอียดการเชื่อมต่อด้วยตนเอง",
"server.connect.camera.error": "ไม่สามารถเปิดกล้องได้ อนุญาตการเข้าถึงกล้องหรือกรอกรายละเอียดการเชื่อมต่อด้วยตนเอง",
"dialog.server.menu.remove": "ลบ",
+2
View File
@@ -791,6 +791,7 @@ export const dict = {
"server.connect.button": "Birikdiriň",
"server.connect.address.invalid": "Dogry HTTP ýa-da HTTPS serwer salgysyny giriziň.",
"server.connect.failed": "Birikip bilmedim Serweriň salgysyny we parolyny barlaň, soňra gaýtadan synanyşyň.",
"server.connect.pair.description": "Baglanyş maglumatlaryňyzy almak üçin bu buýrugy kompýuteriňizde işlediň.",
"server.connect.scan": "QR koduny skanirläň",
"server.connect.scan.description": "Kamerany opencode pair görkezýän QR koda gönükdiriň.",
"server.connect.scan.invalid": "Bu OpenCode jübütleme kody däl. opencode pair görkezýän kody skanirläň.",
@@ -798,6 +799,7 @@ export const dict = {
"server.connect.camera.starting": "Kamerany açmak…",
"server.connect.mixedContent":
"Bu HTTP serwerine HTTPS sahypasyndan birigip bilmedim. Munuň ýerine HTTPS serwer salgysyny ulanyň.",
"server.connect.camera.insecure": "QR gözlemek bu sahypany HTTPS ýa-da ýerlihostda açmagy talap edýär.",
"server.connect.camera.unavailable": "Bu brauzerde kamera ýok. Birikdiriş maglumatlaryňyzy el bilen giriziň.",
"server.connect.camera.error":
"Kamerany açyp bolmady Kamera girmegine rugsat beriň ýa-da baglanyşyk maglumatlaryňyzy el bilen giriziň.",
+2
View File
@@ -860,6 +860,7 @@ export const dict = {
"server.connect.button": "Bağlan",
"server.connect.address.invalid": "Geçerli bir HTTP veya HTTPS sunucu adresi girin.",
"server.connect.failed": "Bağlantı kurulamadı. Sunucu adresini ve şifreyi kontrol edip tekrar deneyin.",
"server.connect.pair.description": "Bağlantı ayrıntılarınızı almak için bilgisayarınızda bu komutu çalıştırın.",
"server.connect.scan": "QR kodunu tarayın",
"server.connect.scan.description": "Kameranızı opencode pair tarafından gösterilen QR koduna doğrultun.",
"server.connect.scan.invalid":
@@ -868,6 +869,7 @@ export const dict = {
"server.connect.camera.starting": "Kamera açılıyor…",
"server.connect.mixedContent":
"Bu HTTP sunucusuna bir HTTPS sayfasından bağlanılamadı. Bunun yerine HTTPS sunucu adresini kullanın.",
"server.connect.camera.insecure": "QR taraması, bu sayfanın HTTPS veya localhost üzerinden açılmasını gerektirir.",
"server.connect.camera.unavailable":
"Bu tarayıcıda kamera mevcut değil. Bağlantı ayrıntılarınızı manuel olarak girin.",
"server.connect.camera.error":
+2
View File
@@ -885,6 +885,7 @@ export const dict = {
"server.connect.button": "Підключитися",
"server.connect.address.invalid": "Введіть дійсну адресу сервера HTTP або HTTPS.",
"server.connect.failed": "Не вдалося підключитися. Перевірте адресу сервера та пароль, потім повторіть спробу.",
"server.connect.pair.description": "Запустіть цю команду на своєму комп’ютері, щоб отримати дані підключення.",
"server.connect.scan": "Сканувати QR-код",
"server.connect.scan.description": "Наведіть камеру на QR-код, показаний opencode pair.",
"server.connect.scan.invalid": "Це не код підключення OpenCode. Відскануйте код, показаний opencode pair.",
@@ -892,6 +893,7 @@ export const dict = {
"server.connect.camera.starting": "Відкриваємо камеру…",
"server.connect.mixedContent":
"Не вдалося підключитися до цього HTTP-сервера з HTTPS-сторінки. Використайте адресу HTTPS-сервера.",
"server.connect.camera.insecure": "Сканування QR вимагає відкриття цієї сторінки через HTTPS або на localhost.",
"server.connect.camera.unavailable": "У цьому веббраузері немає доступної камери. Введіть дані підключення вручну.",
"server.connect.camera.error":
"Не вдалося відкрити камеру. Дозвольте доступ до камери або введіть дані підключення вручну.",
+2
View File
@@ -804,6 +804,7 @@ export const dict = {
"server.connect.button": "جڑیں۔",
"server.connect.address.invalid": "ایک درست HTTP یا HTTPS سرور کا پتہ درج کریں۔",
"server.connect.failed": "رابطہ نہیں ہو سکا۔ سرور کا پتہ اور پاس ورڈ چیک کریں، پھر دوبارہ کوشش کریں۔",
"server.connect.pair.description": "اپنے کنکشن کی تفصیلات حاصل کرنے کے لیے اس کمانڈ کو اپنے کمپیوٹر پر چلائیں۔",
"server.connect.scan": "QR کوڈ اسکین کریں۔",
"server.connect.scan.description": "اپنے کیمرے کو اوپن کوڈ پیئر کے ذریعے دکھائے گئے QR کوڈ کی طرف پوائنٹ کریں۔",
"server.connect.scan.invalid":
@@ -812,6 +813,7 @@ export const dict = {
"server.connect.camera.starting": "کیمرہ کھل رہا ہے…",
"server.connect.mixedContent":
"ایک HTTPS صفحہ سے اس HTTP سرور سے مربوط نہیں ہو سکا۔ اس کے بجائے ایک HTTPS سرور کا پتہ استعمال کریں۔",
"server.connect.camera.insecure": "QR اسکیننگ کے لیے اس صفحہ کو HTTPS پر یا لوکل ہوسٹ پر کھولنے کی ضرورت ہے۔",
"server.connect.camera.unavailable":
"اس براؤزر پر کوئی کیمرہ دستیاب نہیں ہے۔ اپنے کنکشن کی تفصیلات دستی طور پر درج کریں۔",
"server.connect.camera.error":
+3
View File
@@ -797,6 +797,8 @@ export const dict = {
"server.connect.button": "Ulanish",
"server.connect.address.invalid": "Yaroqli HTTP yoki HTTPS server manzilini kiriting.",
"server.connect.failed": "Ulanib bo‘lmadi. Server manzili va parolni tekshiring, keyin qayta urinib ko'ring.",
"server.connect.pair.description":
"Ulanish ma'lumotlarini olish uchun ushbu buyruqni kompyuteringizda ishga tushiring.",
"server.connect.scan": "QR kodini skanerlang",
"server.connect.scan.description": "Kamerani opencode pair koʻrsatgan QR kodga qarating.",
"server.connect.scan.invalid": "Bu OpenCode ulash kodi emas. opencode pair koʻrsatgan kodni skanerlang.",
@@ -804,6 +806,7 @@ export const dict = {
"server.connect.camera.starting": "Kamera ochilmoqda…",
"server.connect.mixedContent":
"HTTPS sahifasidan ushbu HTTP serveriga ulanib boʻlmadi. Buning o'rniga HTTPS server manzilidan foydalaning.",
"server.connect.camera.insecure": "QR skanerlash uchun bu sahifani HTTPS yoki localhost orqali ochish kerak.",
"server.connect.camera.unavailable":
"Bu brauzerda hech qanday kamera mavjud emas. Ulanish ma'lumotlarini qo'lda kiriting.",
"server.connect.camera.error":
+2
View File
@@ -801,6 +801,7 @@ export const dict = {
"server.connect.button": "Kết nối",
"server.connect.address.invalid": "Nhập địa chỉ máy chủ HTTP hoặc HTTPS hợp lệ.",
"server.connect.failed": "Không thể kết nối. Kiểm tra địa chỉ máy chủ và mật khẩu, sau đó thử lại.",
"server.connect.pair.description": "Chạy lệnh này trên máy tính của bạn để lấy thông tin kết nối.",
"server.connect.scan": "Quét mã QR",
"server.connect.scan.description": "Chĩa camera của bạn vào mã QR được hiển thị bởi cặp opencode.",
"server.connect.scan.invalid": "Đây không phải mã ghép cặp OpenCode. Quét mã được hiển thị bởi ghép cặp opencode.",
@@ -808,6 +809,7 @@ export const dict = {
"server.connect.camera.starting": "Đang mở camera…",
"server.connect.mixedContent":
"Không thể kết nối với máy chủ HTTP này từ trang HTTPS. Sử dụng địa chỉ máy chủ HTTPS thay vào đó.",
"server.connect.camera.insecure": "Quét QR yêu cầu mở trang này qua HTTPS hoặc trên localhost.",
"server.connect.camera.unavailable": "Trình duyệt này không có camera. Nhập chi tiết kết nối của bạn thủ công.",
"server.connect.camera.error":
"Không thể mở camera. Cho phép truy cập camera hoặc nhập chi tiết kết nối của bạn thủ công.",
+2
View File
@@ -849,12 +849,14 @@ export const dict = {
"server.connect.button": "连接",
"server.connect.address.invalid": "请输入有效的 HTTP 或 HTTPS 服务器地址。",
"server.connect.failed": "无法连接。请检查服务器地址和密码,然后重试。",
"server.connect.pair.description": "在您的计算机上运行此命令以获取连接详细信息。",
"server.connect.scan": "扫描二维码",
"server.connect.scan.description": "将摄像头对准 opencode pair 显示的二维码。",
"server.connect.scan.invalid": "这不是 OpenCode 配对码。请扫描 opencode pair 显示的二维码。",
"server.connect.camera": "正在配对摄像头",
"server.connect.camera.starting": "正在打开摄像头…",
"server.connect.mixedContent": "无法从 HTTPS 页面连接此 HTTP 服务器。请使用 HTTPS 服务器地址。",
"server.connect.camera.insecure": "扫描二维码需要通过 HTTPS 打开此页面或在本地主机上打开。",
"server.connect.camera.unavailable": "此浏览器无法使用摄像头。请手动输入连接详情。",
"server.connect.camera.error": "无法打开摄像头。请允许摄像头访问或手动输入连接详情。",
"dialog.server.menu.remove": "移除",
+2
View File
@@ -841,12 +841,14 @@ export const dict = {
"server.connect.button": "連線",
"server.connect.address.invalid": "請輸入有效的 HTTP 或 HTTPS 伺服器位址。",
"server.connect.failed": "無法連線。請檢查伺服器位址和密碼,然後重試。",
"server.connect.pair.description": "在您的電腦上執行此命令以取得連線詳細資料。",
"server.connect.scan": "掃描 QR 碼",
"server.connect.scan.description": "將攝影機對準 opencode pair 顯示的 QR 碼。",
"server.connect.scan.invalid": "這不是 OpenCode 配對碼。請掃描 opencode pair 顯示的 QR 碼。",
"server.connect.camera": "配對攝影機",
"server.connect.camera.starting": "正在開啟攝影機…",
"server.connect.mixedContent": "無法從 HTTPS 頁面連線至此 HTTP 伺服器。請改用 HTTPS 伺服器位址。",
"server.connect.camera.insecure": "QR 碼掃描需要透過 HTTPS 或在 localhost 上開啟此頁面。",
"server.connect.camera.unavailable": "此瀏覽器無可用的攝影機。請手動輸入您的連線資訊。",
"server.connect.camera.error": "無法開啟攝影機。請允許攝影機存取或手動輸入您的連線資訊。",
"dialog.server.menu.remove": "移除",
+9 -28
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner, untrack } from "solid-js"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -36,32 +36,16 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
// The credentials each controller started with, as plain values: the listed connection is a store proxy that
// already reads the new password once the user saves it.
const serverCtxCredentials = new Map<ServerConnection.Key, string>()
const owner = getOwner()
if (!owner) throw new Error("Global provider requires a Solid owner")
const disposeServerCtx = (key: ServerConnection.Key) => {
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
serverCtxCredentials.delete(key)
serverCtxs.delete(key)
}
const ensureServerCtx = (input: ServerConnection.Any) => {
const key = ServerConnection.key(input)
// Callers can hold an older copy of the connection; the listed one has the credentials the user saved last.
const conn = untrack(() => server.list.find((item) => ServerConnection.key(item) === key)) ?? input
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
if (existing && serverCtxCredentials.get(key) === credentials(conn)) return existing
// A controller keeps the credentials it started with, so a server signed in again under the same address needs
// a new one.
if (existing) disposeServerCtx(key)
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
@@ -70,7 +54,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
}, owner)
serverCtxs.set(key, serverCtx)
serverCtxCredentials.set(key, credentials(conn))
return serverCtx
}
@@ -86,8 +69,11 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
createEffect(() => {
for (const [key] of serverCtxs) {
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key))
disposeServerCtx(key)
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) {
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
serverCtxs.delete(key)
}
}
})
@@ -284,8 +270,3 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
/** What an HTTP server's controller authenticates with; other servers keep one controller per id. */
function credentials(conn: ServerConnection.Any) {
return conn.type === "http" ? `${conn.http.url}\n${conn.http.password ?? ""}` : ""
}
@@ -26,8 +26,3 @@ export function createCameraAvailability() {
return { supported, available, refetch: actions.refetch }
}
// Pages served over plain HTTP cannot open the camera. QR codes carry JSON, not a link, so the pasted link is the way in.
export function cameraHint() {
return window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.native"
}
+70 -208
View File
@@ -1,7 +1,6 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
import { Divider } from "@opencode/ui/divider"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { useDialog } from "@opencode/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
@@ -29,10 +28,8 @@ import { useTabs } from "@/shell/tabs/tabs"
import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { cameraHint, createCameraAvailability } from "./camera"
import { bareServerAddress, pairingLink, type Pairing } from "./pairing"
import { useRedeemPairing } from "./redeem"
import { ConnectMethodSwitch, type ConnectMethod } from "./method"
import { createCameraAvailability } from "./camera"
import type { Pairing } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -50,10 +47,9 @@ export const DialogServer: Component<{
const camera = createCameraAvailability()
const form = createFormController({
// Close first: onSave may open the next dialog, such as the folder picker an interrupted action continues with.
onSelect: (server) => {
dialog.close()
props.onSave?.(server)
dialog.close()
},
})
@@ -86,14 +82,6 @@ export const DialogServer: Component<{
const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const error = () => (
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
</Show>
)
const submitLabel = () => {
if (form.state.busy()) return language.t("dialog.server.add.checking")
@@ -118,74 +106,36 @@ export const DialogServer: Component<{
form.scan.stop()
void camera.refetch()
}}
redeem={form.scan.redeem}
onScan={form.scan.complete}
/>
</Suspense>
}
>
<div class="flex w-full min-w-0 flex-col gap-6">
<Show when={form.state.signedOut()}>
<p class="settings-server-dialog-notice" role="status">
<Icon name="lock" size="small" />
<span>{language.t("dialog.server.signedOut")}</span>
</p>
</Show>
<Show
when={form.state.method() === "password"}
fallback={
<div class="flex w-full min-w-0 flex-col gap-2">
<label for="dialog-server-link" class="settings-server-dialog-label">
{language.t("server.connect.link")}
</label>
<TextInput
id="dialog-server-link"
type="text"
appearance="large"
class="!w-full self-stretch"
dir="ltr"
spellcheck={false}
autocapitalize="off"
value={form.state.link()}
placeholder={language.t("server.connect.link.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.link(event.currentTarget.value)}
onKeyDown={keyDown}
/>
{error()}
</div>
}
>
<div class="flex w-full min-w-0 flex-col gap-2">
<label for="dialog-server-url" class="settings-server-dialog-label">
{language.t("dialog.server.add.url")}
</label>
<TextInput
id="dialog-server-url"
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
{error()}
</div>
</Show>
<div class="flex w-full min-w-0 flex-col gap-2">
<label for="dialog-server-name" class="settings-server-dialog-label">
{language.t("dialog.server.add.name")}
</label>
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInput
id="dialog-server-name"
type="text"
appearance="large"
class="!w-full self-stretch"
@@ -196,49 +146,44 @@ export const DialogServer: Component<{
onKeyDown={keyDown}
/>
</div>
<Show when={form.state.method() === "password"}>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInput
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<Show when={props.mode === "add" && platform.platform === "web"}>
<div class="flex w-full min-w-0 flex-col gap-2">
<label for="dialog-server-password" class="settings-server-dialog-label">
{language.t("dialog.server.add.password")}
</label>
<TextInput
id="dialog-server-password"
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
aria-describedby="dialog-server-password-hint"
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<span id="dialog-server-password-hint" class="settings-server-dialog-hint">
{language.t("server.connect.password.hint")}
</span>
</div>
</Show>
<Show when={form.state.method() === "link" && platform.platform === "web"}>
<Show
when={camera.available.latest || camera.available.loading}
fallback={<span class="settings-server-dialog-hint">{language.t(cameraHint())}</span>}
>
<Button
variant="neutral"
size="large"
class="!w-full self-stretch"
disabled={form.state.busy() || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading
? "dialog-server-camera-unavailable"
: undefined
}
onClick={form.scan.start}
>
{language.t("server.connect.scan")}
</Button>
</Show>
<Show when={!camera.available.latest && !camera.available.loading}>
<span id="dialog-server-camera-unavailable" class="settings-server-dialog-hint">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
)}
</span>
</Show>
</div>
</Show>
<ConnectMethodSwitch
method={form.state.method()}
disabled={form.state.busy()}
onChange={form.change.method}
/>
</div>
</Show>
</DialogBody>
@@ -263,27 +208,15 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
const global = useGlobal()
const language = useLanguage()
const checkServerHealth = useCheckServerHealth()
const redeem = useRedeemPairing()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore<{
mode: FormMode
method: ConnectMethod
originalUrl: string | undefined
link: string
values: ServerFormValues
scanning: boolean
error: string
status: boolean | undefined
}>({
mode: "list",
method: "link",
originalUrl: undefined,
link: "",
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", password: "" },
scanning: false,
error: "",
status: undefined,
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
@@ -292,9 +225,7 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
healthPreview.cancel()
setStore({
mode: "list",
method: "link",
originalUrl: undefined,
link: "",
values: { url: "", name: "", password: "" },
scanning: false,
error: "",
@@ -319,68 +250,24 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
remove: (key) => server.remove(key),
})
// A pairing token works on every address of its server. Signing in again to the edited server keeps its address
// (e.g. localhost) even when the link names another one (e.g. 127.0.0.1 from opencode pair).
const paired = async (pairing: Pairing) => {
const original = store.mode === "edit" ? editing() : undefined
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!original || original.http.url === pairing.url) return pairing
const http = { url: original.http.url, password: pairing.password }
return (await checkServerHealth(http)).healthy ? http : pairing
}
// Where the form points: a scanned or pasted pairing link wins, else the address and password fields.
const target = async (): Promise<ServerConnection.HttpBase | undefined> => {
if (store.method === "link") {
const redeemed = await redeem(store.link)
if (redeemed && "error" in redeemed) return void setStore("error", redeemed.error)
if (redeemed) return paired(redeemed.pairing)
// A server address typed here belongs to the password form.
const address = bareServerAddress(store.link)
if (address) {
setStore({ method: "password", link: "", values: { ...store.values, url: address } })
preview()
if (!normalized) {
reset()
return
}
return void setStore("error", language.t("server.connect.link.invalid"))
}
// A pairing link pasted into the address field still pairs.
const redeemed = await redeem(store.values.url)
if (redeemed && "error" in redeemed) return void setStore("error", redeemed.error)
if (redeemed) return paired(redeemed.pairing)
const url = normalizeServerUrl(store.values.url)
if (!url) return void reset()
return { url, password: store.values.password || undefined }
}
const request = useMutation(() => ({
mutationFn: async () => {
const http = await target()
if (!http) return
const normalized = http.url
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const password = http.password
const password = store.values.password || undefined
// Nothing changed: close, unless the server rejects these credentials, so saving checks them and the action that
// asked for sign-in can continue.
if (
original?.type === "http" &&
!global.servers.health[ServerConnection.key(original)]?.unauthorized &&
normalized === original.http.url &&
name === original.displayName &&
password === original.http.password
@@ -418,8 +305,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
if (normalized === original.http.url) add(connection)
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
reset()
options.onSelect?.(connection)
reset()
return
}
@@ -430,17 +317,7 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
},
}))
const preview = () => {
// A pairing link is not a server address until it is redeemed, and redeeming spends it.
if (store.method === "link" || pairingLink(store.values.url)) {
healthPreview.cancel()
setStore("status", undefined)
return
}
void healthPreview.preview(store.values, (status) => setStore("status", status))
}
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
@@ -459,8 +336,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
reset()
setStore({
mode: "edit",
// A signed-out server needs a new pairing link; otherwise editing starts from its address.
method: global.servers.health[ServerConnection.key(connection)]?.unauthorized ? "link" : "password",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
@@ -478,9 +353,13 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
request.mutate()
}
const pair = (link: string) => {
const pair = (pairing: Pairing) => {
healthPreview.cancel()
setStore({ method: "link", link, scanning: false, error: "" })
setStore({
values: { ...store.values, url: pairing.url, password: pairing.password },
scanning: false,
error: "",
})
request.mutate()
}
@@ -497,30 +376,14 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
method: () => store.method,
link: () => store.link,
value: () => store.values.url,
name: () => store.values.name,
password: () => store.values.password,
scanning: () => store.scanning,
error: () => store.error,
status: () => store.status,
signedOut: () => {
const original = editing()
return !!original && !!global.servers.health[ServerConnection.key(original)]?.unauthorized
},
},
change: {
method: (method: ConnectMethod) => {
if (request.isPending) return
setStore({ method, error: "" })
preview()
},
link: (value: string) => {
if (request.isPending) return
setStore({ link: value, error: "" })
},
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
password: (value: string) => change("password", value),
@@ -529,7 +392,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
start: () => setStore("scanning", true),
stop: () => setStore("scanning", false),
complete: pair,
redeem,
},
start: { add: startAdd, edit: startEdit },
reset,
@@ -1,28 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
export type ConnectMethod = "link" | "password"
// Deliberately quiet, like the Console's API key switch: a pairing link is the way in, the password is the fallback.
export function ConnectMethodSwitch(props: {
method: ConnectMethod
disabled?: boolean
onChange: (method: ConnectMethod) => void
}) {
const language = useLanguage()
const next = () => (props.method === "link" ? "password" : "link")
return (
<div
data-component="server-connect-method"
class="flex min-h-7 flex-wrap items-center justify-center gap-1 text-[13px]"
>
<span class="text-v2-text-text-faint">
{language.t(props.method === "link" ? "server.connect.password.prompt" : "server.connect.link.prompt")}
</span>
<Button variant="ghost-muted" disabled={props.disabled} onClick={() => props.onChange(next())}>
{language.t(props.method === "link" ? "server.connect.password.use" : "server.connect.link.use")}
</Button>
</div>
)
}
@@ -1,21 +1,10 @@
import { describe, expect, test } from "bun:test"
import { bareServerAddress, legacyPairingLink, pairingLink } from "./pairing"
import { pairingLink } from "./pairing"
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
urls: ["http://192.168.1.2:49374"],
code: "abc_DEF-123",
})
})
test("reads every server address from opencode pair QR codes", () => {
expect(
pairingLink(
JSON.stringify({ code: "abc_DEF-123", urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"] }),
),
).toEqual({
urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"],
url: "http://192.168.1.2:49374",
code: "abc_DEF-123",
})
})
@@ -27,31 +16,4 @@ describe("pairing link", () => {
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
})
test("rejects malformed QR payloads", () => {
expect(pairingLink(JSON.stringify({ code: "abc", urls: [] }))).toBeUndefined()
expect(pairingLink(JSON.stringify({ code: "a/b", urls: ["http://192.168.1.2:49374"] }))).toBeUndefined()
expect(pairingLink(JSON.stringify({ code: "abc", urls: ["opencode-ios://host"] }))).toBeUndefined()
expect(pairingLink(JSON.stringify({ urls: ["http://192.168.1.2:49374"] }))).toBeUndefined()
expect(pairingLink("{not json")).toBeUndefined()
})
test("treats only a host without a path as a server address typed into the pairing link field", () => {
expect(bareServerAddress("192.168.1.2:4096")).toBe("http://192.168.1.2:4096")
expect(bareServerAddress("https://opencode.example.com/")).toBe("https://opencode.example.com")
expect(bareServerAddress("localhost")).toBe("http://localhost")
expect(bareServerAddress("devbox:4096")).toBe("http://devbox:4096")
expect(bareServerAddress("[::1]:4096")).toBe("http://[::1]:4096")
expect(bareServerAddress("abc_DEF-123")).toBeUndefined()
expect(bareServerAddress("http://192.168.1.2:4096/auth/connect/")).toBeUndefined()
expect(bareServerAddress("http://192.168.1.2:4096/auth/connect/…")).toBeUndefined()
expect(bareServerAddress("http://192.168.1.2:4096/connect")).toBeUndefined()
})
test("recognizes the credential links that servers printed before one-time links", () => {
expect(legacyPairingLink("http://192.168.1.2:49374/connect#eyJ1c2VybmFtZSI6Im9wZW5jb2RlIn0")).toBe(true)
expect(legacyPairingLink("http://192.168.1.2:49374/connect?data=eyJ1c2VybmFtZSI6Im9wZW5jb2RlIn0")).toBe(true)
expect(legacyPairingLink("http://192.168.1.2:49374/connect")).toBe(false)
expect(legacyPairingLink("http://192.168.1.2:49374/auth/connect/abc")).toBe(false)
})
})
+13 -105
View File
@@ -1,6 +1,4 @@
import { isUnauthorizedError, OpenCode } from "@opencode/client/promise"
import { Option, Schema } from "effect"
import { checkServerHealth } from "@/runtime/server/health"
import { OpenCode } from "@opencode/client/promise"
import { normalizeServerUrl } from "@/runtime/server/registry"
export function serverAddress(value: string) {
@@ -17,116 +15,26 @@ export function serverAddress(value: string) {
return normalized
}
const CODE = /^[A-Za-z0-9_-]+$/
const decodePayload = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ code: Schema.String, urls: Schema.Array(Schema.String) })),
)
// `opencode pair` prints links carrying a single-use code that the server exchanges for a session token.
// Its QR code carries the same code with every reachable server address as {"code","urls"} JSON.
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
export function pairingLink(value: string) {
const trimmed = value.trim()
if (trimmed.startsWith("{")) {
const payload = Option.getOrUndefined(decodePayload(trimmed))
if (!payload || !CODE.test(payload.code)) return
const urls = [...new Set(payload.urls)].map(serverAddress)
if (urls.length === 0 || urls.some((url) => url === undefined)) return
return { urls: urls.filter((url) => url !== undefined), code: payload.code }
}
const url = URL.parse(trimmed)
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
const code = /^\/auth\/connect\/([^/]+)$/.exec(url.pathname)?.[1]
const address = serverAddress(url.origin)
if (!code || !CODE.test(code) || !address) return
return { urls: [address], code }
}
// A server address typed where a pairing link was expected: a host with no path, so a bare code or a cut-off link
// is not mistaken for a server. Paths stay allowed in the password form, where servers behind a proxy prefix live.
export function bareServerAddress(value: string) {
const address = serverAddress(value)
if (!address) return
const url = new URL(address)
if (url.pathname !== "/") return
if (url.hostname !== "localhost" && !url.hostname.includes(".") && !url.hostname.startsWith("[") && !url.port) return
return address
}
// Servers before one-time links paired with `/connect#<credentials>` or `/connect?data=<credentials>`.
export function legacyPairingLink(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return false
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
const address = serverAddress(url.origin)
return url.pathname === "/connect" && (url.hash.length > 1 || url.searchParams.has("data"))
if (!code || !address) return
return { url: address, code }
}
export type Pairing = { readonly url: string; readonly password: string }
export type Redeemed =
| { readonly type: "paired"; readonly pairing: Pairing }
| { readonly type: "expired" }
| { readonly type: "unreachable" }
// Every address may reach the same server, but the code is single-use, so at most one attempt succeeds.
export function redeemPairingLink(link: { urls: ReadonlyArray<string>; code: string }) {
const attempts = link.urls.map((url) =>
OpenCode.make({ baseUrl: url })
.server.connect({ code: link.code }, { signal: AbortSignal.timeout(5_000) })
.then((session): Pairing => ({ url, password: session.token })),
)
// Whether the first address answered at all: a 401 there means another address spent the code first.
const first = attempts[0]?.then(
() => true,
(error) => isUnauthorizedError(error),
)
return Promise.any(attempts)
.then((pairing) => preferFirst(link.urls, pairing, first))
export function redeemPairingLink(link: { url: string; code: string }) {
return OpenCode.make({ baseUrl: link.url })
.server.connect({ code: link.code })
.then(
(pairing): Redeemed => ({ type: "paired", pairing }),
// Only a server's answer proves the code is spent; addresses that could not be reached leave it valid.
(error): Redeemed =>
error instanceof AggregateError && error.errors.some(isUnauthorizedError)
? { type: "expired" }
: { type: "unreachable" },
(session): Pairing => ({ url: link.url, password: session.token }),
() => undefined,
)
}
// The token works on every address of the server, so keep the first one (the address the user chose in Pairing) when it
// answers, even if a nearer address answered sooner: a local network address stops working away from that network. An
// address that has not answered within a second, such as a local one seen from elsewhere, is not waited for.
async function preferFirst(
urls: ReadonlyArray<string>,
pairing: Pairing,
answered: Promise<boolean> | undefined,
): Promise<Pairing> {
const first = urls[0]
if (!first || first === pairing.url || !answered) return pairing
const reachable = await Promise.race([
answered,
new Promise<false>((resolve) => setTimeout(() => resolve(false), 1_000)),
])
if (!reachable) return pairing
const http = { url: first, password: pairing.password }
const health = await checkServerHealth(http, globalThis.fetch, { signal: AbortSignal.timeout(5_000), retryCount: 0 })
return health.healthy ? http : pairing
}
@@ -1,52 +0,0 @@
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { legacyPairingLink, pairingLink, redeemPairingLink, type Pairing } from "./pairing"
export type Redemption = { readonly pairing: Pairing } | { readonly error: string }
/** Redeems a pasted or scanned pairing link; resolves undefined when the value is not a pairing link at all. */
export type RedeemPairing = (value: string) => Promise<Redemption | undefined>
/** The code of the last link a form redeemed, and the token the server returned for it. */
type Redeemed = { code?: string; pairing?: Pairing }
// Pasted and scanned links share one redeem path so every surface explains a failure the same way.
// Each form keeps the last token it redeemed: the code is spent, so a retry after a failed connection check reuses it,
// whichever form the link takes (a scanned QR payload, or the link the scanner then shows).
export function useRedeemPairing(): RedeemPairing {
const language = useLanguage()
const platform = usePlatform()
const last: Redeemed = {}
return async (value) => {
const link = pairingLink(value)
if (!link) return legacyPairingLink(value) ? { error: language.t("server.connect.link.legacy") } : undefined
// The same code again: reuse its token. An address the user edited since (another forwarded port, say) wins; the
// token works on every address of the server.
if (last.pairing && last.code === link.code) {
const url = link.urls.includes(last.pairing.url) ? last.pairing.url : (link.urls[0] ?? last.pairing.url)
return { pairing: { url, password: last.pairing.password } }
}
const result = await redeemPairingLink(link)
if (result.type === "paired") {
last.code = link.code
last.pairing = result.pairing
return { pairing: result.pairing }
}
if (result.type === "expired") return { error: language.t("server.connect.link.expired") }
// A QR code can carry several addresses; the HTTPS page can reach none of them only when every one is plain HTTP.
if (platform.platform === "web" && link.urls.every((url) => isMixedContent(location.href, url)))
return { error: language.t("server.connect.mixedContent") }
return { error: language.t("server.connect.link.unreachable", { url: link.urls.join(", ") }) }
}
}
@@ -38,10 +38,4 @@
display: grid;
place-items: center;
}
/* Keep the status readable over the live preview while a scanned code is redeemed. */
.server-connect-video [role="status"][data-busy] {
background: color-mix(in srgb, var(--v2-background-bg-deep) 72%, transparent);
color: var(--v2-text-text-base);
}
}
+16 -45
View File
@@ -3,69 +3,42 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { pairingLink } from "./pairing"
import type { RedeemPairing } from "./redeem"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import "./scanner.css"
// Redeems with the form's own redeem, so the form keeps the token and a failed connection check retries without the
// spent code; the form gets back a link it can show, and fills it in.
export function PairingScanner(props: { redeem: RedeemPairing; onScan: (link: string) => void; onCancel: () => void }) {
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false, redeeming: false })
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
video.setAttribute("aria-label", language.t("server.connect.camera"))
video.setAttribute("playsinline", "")
video.muted = true
onMount(() => {
// A code that failed usually stays in view, so skip it for a few seconds instead of redeeming it on every frame.
// It is tried again afterwards: a code the server could not be reached for still works once the network is back.
const rejected = new Map<string, number>()
const scan = { disposed: false }
// QrScanner hides detached videos, so initialize only after this preview is mounted.
const scanner = new QrScanner(
video,
(result) => {
if (state.redeeming || Date.now() < (rejected.get(result.data) ?? 0)) return
setState({ redeeming: true, error: "" })
void props.redeem(result.data).then((redeemed) => {
if (scan.disposed) return
setState("redeeming", false)
const link = pairingLink(result.data)
if (redeemed && "pairing" in redeemed) {
scanner.stop()
// A QR code carries {"code","urls"} JSON; the form shows the link for the address that paired instead.
const code = pairingLink(result.data)?.code
props.onScan(code ? new URL(`/auth/connect/${code}`, redeemed.pairing.url).href : result.data)
if (!link) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
return
}
rejected.set(result.data, Date.now() + 3000)
setState("error", redeemed?.error ?? language.t("server.connect.scan.invalid"))
scanner.stop()
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
})
},
{
preferredCamera: "environment",
maxScansPerSecond: 10,
returnDetailedScanResult: true,
// QrScanner only logs a decoder that fails (for example, a worker the page may not start), so the preview
// would look like it is still scanning.
onDecodeError: (error) => {
// The native BarcodeDetector engine reports an empty frame as "Scanner error: No QR code found".
if (String(error).includes(QrScanner.NO_QR_CODE_FOUND) || state.redeeming) return
setState("error", language.t("server.connect.scan.failed"))
},
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
// Terminal QR codes can be light-on-dark depending on the terminal theme.
scanner.setInversionMode("both")
onCleanup(() => {
scan.disposed = true
scanner.destroy()
})
onCleanup(() => scanner.destroy())
void scanner.start().then(
() => setState("ready", true),
() => setState("error", language.t("server.connect.camera.error")),
@@ -77,10 +50,8 @@ export function PairingScanner(props: { redeem: RedeemPairing; onScan: (link: st
<p>{language.t("server.connect.scan.description")}</p>
<div class="server-connect-video">
{video}
<Show when={(!state.ready && !state.error) || state.redeeming}>
<span role="status" data-busy={state.redeeming ? "" : undefined}>
{language.t(state.redeeming ? "dialog.server.add.checking" : "server.connect.camera.starting")}
</span>
<Show when={!state.ready && !state.error}>
<span role="status">{language.t("server.connect.camera.starting")}</span>
</Show>
</div>
<Show when={state.error}>
@@ -74,17 +74,6 @@
width: 100%;
}
/* The method switch is a quiet inline link, not a full-width action. */
[data-component="server-connect-method"] button {
min-height: 28px;
width: auto;
}
.server-connect-hint {
font-size: 12px;
line-height: var(--line-height-compact);
}
.server-connect-error {
color: var(--v2-state-fg-danger);
}
+93 -171
View File
@@ -7,12 +7,10 @@ import { Wordmark } from "@opencode/ui/wordmark"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { bareServerAddress, serverAddress, type Pairing } from "./pairing"
import { useRedeemPairing } from "./redeem"
import { useServers } from "@/runtime/server/registry"
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import { cameraHint, createCameraAvailability } from "./camera"
import { ConnectMethodSwitch, type ConnectMethod } from "./method"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -22,95 +20,55 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const redeem = useRedeemPairing()
const camera = createCameraAvailability()
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
const [state, setState] = createStore<{
method: ConnectMethod
link: string
url: string
password: string
error: string
scanning: boolean
}>({
method: "link",
link: "",
url: props.url ?? "",
password: "",
error: "",
scanning: false,
})
// A pairing token works on every address of its server. Signing in again to the server that signed out keeps its
// address (e.g. localhost) even when the link names another one (e.g. 127.0.0.1 from opencode pair).
const target = async (pairing: Pairing) => {
if (!props.url || props.url === pairing.url) return pairing
const http = { url: props.url, password: pairing.password }
return (await check(http)).healthy ? http : pairing
}
const connect = async (http: ServerConnection.HttpBase) => {
const result = await check(http)
if (!result.healthy) {
setState("error", connectionError(http.url))
return
}
// Signing in again keeps the name the user gave this server.
const existing = servers.list.find(
(server): server is ServerConnection.Http => server.type === "http" && server.http.url === http.url,
)
servers.add(existing ? { ...existing, http } : { type: "http", http })
}
const connectionError = (url: string) =>
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, url)
platform.platform === "web" && isMixedContent(location.href, state.url)
? "server.connect.mixedContent"
: "server.connect.failed",
)
const request = useMutation(() => ({
mutationFn: async () => {
if (state.method === "link") {
const redeemed = await redeem(state.link)
const link = pairingLink(state.url)
if (redeemed && "error" in redeemed) return void setState("error", redeemed.error)
if (link) {
const redeemed = await redeemPairingLink(link)
if (redeemed) return connect(await target(redeemed.pairing))
// A server address typed here belongs to the password form.
const address = bareServerAddress(state.link)
if (!redeemed) {
setState("error", language.t("server.connect.link.expired"))
if (address) return void setState({ method: "password", url: address, link: "", error: "" })
return
}
return void setState("error", language.t("server.connect.link.invalid"))
// Keep the token in the form so a failed connection check can retry without the spent code.
setState({ url: link.url, password: redeemed.password })
}
// A pairing link pasted into the address field still pairs.
const redeemed = await redeem(state.url)
if (redeemed && "error" in redeemed) return void setState("error", redeemed.error)
if (redeemed) return connect(await target(redeemed.pairing))
const url = serverAddress(state.url)
if (!url) return void setState("error", language.t("server.connect.address.invalid"))
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
await connect({ url, password: state.password || undefined })
return
}
const http = { url, password: state.password || undefined }
const result = await check(http)
if (!result.healthy) {
setState("error", connectionError())
return
}
servers.add({ type: "http", http })
},
onError: () => setState("error", connectionError(state.method === "link" ? state.link : state.url)),
onError: () => setState("error", connectionError()),
}))
const submit = () => {
if (request.isPending) return
setState("error", "")
request.mutate()
}
return (
<main data-component="connect-server" aria-labelledby="server-connect-title">
<div class="server-connect-content">
@@ -119,22 +77,19 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
</div>
<header>
<h1 id="server-connect-title">{language.t("server.connect.title")}</h1>
<p>
{language.t(state.method === "link" ? "server.connect.description.pairing" : "server.connect.description")}
</p>
<p>{language.t("server.connect.description")}</p>
</header>
<Show
when={!state.scanning}
fallback={
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
<PairingScanner
redeem={redeem}
onCancel={() => {
setState("scanning", false)
void camera.refetch()
}}
onScan={(link) => {
setState({ method: "link", link, error: "", scanning: false })
onScan={(pairing) => {
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
request.mutate()
}}
/>
@@ -144,111 +99,78 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
<form
onSubmit={(event) => {
event.preventDefault()
submit()
if (request.isPending) return
setState("error", "")
request.mutate()
}}
>
<Show
when={state.method === "password"}
fallback={
<div class="server-connect-field">
<label for="server-connect-link">{language.t("server.connect.link")}</label>
<TextInput
id="server-connect-link"
name="link"
dir="ltr"
type="text"
inputMode="url"
autocapitalize="off"
spellcheck={false}
required
appearance="large"
placeholder={language.t("server.connect.link.placeholder")}
value={state.link}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ link: event.currentTarget.value, error: "" })}
/>
</div>
}
>
<div class="server-connect-field">
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
<TextInput
id="server-connect-url"
name="server"
dir="ltr"
type="text"
inputMode="url"
autocomplete="url"
autocapitalize="off"
spellcheck={false}
required
appearance="large"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
<TextInput
id="server-connect-password"
name="password"
type="password"
autocomplete="current-password"
appearance="large"
value={state.password}
disabled={request.isPending}
aria-describedby="server-connect-password-hint"
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
/>
<p id="server-connect-password-hint" class="server-connect-hint">
{language.t("server.connect.password.hint")}
</p>
</div>
</Show>
<div class="server-connect-field">
<label for="server-connect-url">{language.t("dialog.server.add.url")}</label>
<TextInput
id="server-connect-url"
name="server"
dir="ltr"
type="text"
inputMode="url"
autocomplete="url"
autocapitalize="off"
spellcheck={false}
required
appearance="large"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
<TextInput
id="server-connect-password"
name="password"
type="password"
autocomplete="current-password"
appearance="large"
value={state.password}
disabled={request.isPending}
onInput={(event) => setState({ password: event.currentTarget.value, error: "" })}
/>
</div>
<Show when={state.error}>
<p id="server-connect-error" class="server-connect-error" role="alert">
{state.error}
</p>
</Show>
<Button
type="submit"
variant="contrast"
size="large"
disabled={request.isPending || !(state.method === "link" ? state.link : state.url).trim()}
>
<Button type="submit" variant="contrast" size="large" disabled={request.isPending || !state.url.trim()}>
{language.t(request.isPending ? "dialog.server.add.checking" : "server.connect.button")}
</Button>
</form>
<Show when={state.method === "link" && platform.platform === "web"}>
<Show
when={camera.available.latest || camera.available.loading}
fallback={<p>{language.t(cameraHint())}</p>}
<Show when={platform.platform === "web"}>
<Button
variant="neutral"
size="large"
disabled={request.isPending || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
}
onClick={() => setState("scanning", true)}
>
<Button
variant="neutral"
size="large"
disabled={request.isPending || !camera.available.latest}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.available.latest && !camera.available.loading}>
<p id="server-connect-camera-unavailable">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
)}
</p>
</Show>
</Show>
<ConnectMethodSwitch
method={state.method}
disabled={request.isPending}
onChange={(method) => setState({ method, error: "" })}
/>
<Show when={state.method === "link"}>
<footer>
<p>{language.t("server.connect.pair.link")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
<footer>
<p>{language.t("server.connect.pair.description")}</p>
<code dir="ltr">opencode pair</code>
</footer>
</Show>
</div>
</main>
-32
View File
@@ -1,9 +1,7 @@
import { createResource } from "solid-js"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { ExternalLink } from "@/runtime/platform/external-link"
import { showToast } from "@/shell/notifications/toast"
import legal from "./legal.svg"
import anomalyBrush from "./anomaly-brush.svg"
import { AnimatedWordmark } from "./animated-wordmark"
@@ -33,7 +31,6 @@ const illustrators = ["usrnk1", "ludvigrask_", "arvsrn", "iamdavidhill"] as cons
export function SettingsAbout(props: { active: boolean }) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const [otherContributors] = createResource(
() => props.active || undefined,
@@ -56,30 +53,6 @@ export function SettingsAbout(props: { active: boolean }) {
</ExternalLink>,
]
let noticesButton: HTMLButtonElement | undefined
const showNotices = async () => {
// The license texts load only when someone reads them.
const loaded = await import("./notices/dialog").catch(() => undefined)
if (!loaded) {
showToast({ variant: "error", title: language.t("settings.about.notices.loadFailed") })
return
}
// dialog.show has no trigger for Kobalte to restore, so closing returns focus to the button by hand.
void dialog.show(() => (
<loaded.default
onCloseAutoFocus={(event) => {
if (!noticesButton?.isConnected) return
event.preventDefault()
noticesButton.focus({ preventScroll: true })
}}
/>
))
}
return (
<div class="settings-about-content">
<div class="settings-about-intro">
@@ -117,11 +90,6 @@ export function SettingsAbout(props: { active: boolean }) {
<p>{language.t("settings.about.description")}</p>
<p>{language.t("settings.about.trademark")}</p>
<p>{language.t("settings.about.typeset")}</p>
<p>
<button ref={noticesButton} type="button" class="settings-about-link" onClick={() => void showNotices()}>
{language.t("settings.about.notices.title")}
</button>
</p>
</div>
<p>{language.t("settings.about.tagline")}</p>
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Elia Hilse, The OpenOOXML Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -1,2 +0,0 @@
BetterOffice
Copyright 2026 Elia Hilse, The OpenOOXML Project
@@ -1,770 +0,0 @@
// Generated from the Cargo.lock of each published BetterOffice engine and the license files of the published
// crate sources. Regenerate it when the @betteroffice packages update; do not edit it by hand.
export const crates = [
{
name: "adler2",
version: "2.0.1",
license: "0BSD OR MIT OR Apache-2.0",
repository: "https://github.com/oyvindln/adler2",
copyright: ["Copyright (c) Jonas Schievink, oyvindln"],
texts: ["mit"],
},
{
name: "allocator-api2",
version: "0.2.21",
license: "MIT OR Apache-2.0",
repository: "https://github.com/zakarumych/allocator-api2",
copyright: ["Copyright (c) Zakarum"],
texts: ["mit"],
},
{
name: "arc-swap",
version: "1.9.2",
license: "MIT OR Apache-2.0",
repository: "https://github.com/vorner/arc-swap",
copyright: ["Copyright (c) 2017 arc-swap developers"],
texts: ["mit"],
},
{
name: "arrayref",
version: "0.3.9",
license: "BSD-2-Clause",
repository: "https://github.com/droundy/arrayref",
copyright: ["Copyright (c) 2015 David Roundy <roundyd@physics.oregonstate.edu>"],
texts: ["bsd-2-clause"],
},
{
name: "arrayvec",
version: "0.7.8",
license: "MIT OR Apache-2.0",
repository: "https://github.com/bluss/arrayvec",
copyright: ['Copyright (c) Ulrik Sverdrup "bluss" 2015-2023'],
texts: ["mit"],
},
{
name: "async-lock",
version: "3.4.2",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/smol-rs/async-lock",
copyright: ["Copyright (c) Stjepan Glavina"],
texts: ["mit"],
},
{
name: "base64",
version: "0.22.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/marshallpierce/rust-base64",
copyright: ["Copyright (c) 2015 Alice Maz"],
texts: ["mit"],
},
{
name: "bitflags",
version: "2.13.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/bitflags/bitflags",
copyright: ["Copyright (c) 2014 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "block-buffer",
version: "0.10.4",
license: "MIT OR Apache-2.0",
repository: "https://github.com/RustCrypto/utils",
copyright: ["Copyright (c) 2018-2019 The RustCrypto Project Developers"],
texts: ["mit"],
},
{
name: "bytemuck",
version: "1.25.1",
license: "Zlib OR Apache-2.0 OR MIT",
repository: "https://github.com/Lokathor/bytemuck",
copyright: ['Copyright (c) 2019 Daniel "Lokathor" Gee.'],
texts: ["mit-2"],
},
{
name: "cfg-if",
version: "1.0.4",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/cfg-if",
copyright: ["Copyright (c) 2014 Alex Crichton"],
texts: ["mit"],
},
{
name: "concurrent-queue",
version: "2.5.0",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/smol-rs/concurrent-queue",
copyright: ["Copyright (c) Stjepan Glavina, Taiki Endo, John Nunley"],
texts: ["mit"],
},
{
name: "core_maths",
version: "0.1.1",
license: "MIT",
repository: "https://github.com/robertbastian/core_maths",
copyright: ["Copyright (c) 2024 Robert Bastian"],
texts: ["mit"],
},
{
name: "crc32fast",
version: "1.5.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/srijs/rust-crc32fast",
copyright: ["Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors"],
texts: ["mit"],
},
{
name: "crossbeam-utils",
version: "0.8.22",
license: "MIT OR Apache-2.0",
repository: "https://github.com/crossbeam-rs/crossbeam",
copyright: ["Copyright (c) 2019 The Crossbeam Project Developers"],
texts: ["mit"],
},
{
name: "crypto-common",
version: "0.1.7",
license: "MIT OR Apache-2.0",
repository: "https://github.com/RustCrypto/traits",
copyright: ["Copyright (c) 2021 RustCrypto Developers"],
texts: ["mit"],
},
{
name: "dashmap",
version: "6.2.1",
license: "MIT",
repository: "https://github.com/xacrimon/dashmap",
copyright: ["Copyright (c) 2019 Acrimon"],
texts: ["mit"],
},
{
name: "digest",
version: "0.10.7",
license: "MIT OR Apache-2.0",
repository: "https://github.com/RustCrypto/traits",
copyright: ["Copyright (c) 2017 Artyom Pavlov"],
texts: ["mit"],
},
{
name: "encoding_rs",
version: "0.8.35",
license: "(Apache-2.0 OR MIT) AND BSD-3-Clause",
repository: "https://github.com/hsivonen/encoding_rs",
copyright: ["Copyright Mozilla Foundation", "Copyright © WHATWG (Apple, Google, Mozilla, Microsoft)."],
texts: ["mit", "bsd-3-clause"],
},
{
name: "equivalent",
version: "1.0.2",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/indexmap-rs/equivalent",
copyright: ["Copyright (c) 2016--2023"],
texts: ["mit"],
},
{
name: "event-listener-strategy",
version: "0.5.4",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/smol-rs/event-listener-strategy",
copyright: ["Copyright (c) John Nunley"],
texts: ["mit"],
},
{
name: "event-listener",
version: "5.4.1",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/smol-rs/event-listener",
copyright: ["Copyright (c) Stjepan Glavina, John Nunley"],
texts: ["mit"],
},
{
name: "fastrand",
version: "2.4.1",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/smol-rs/fastrand",
copyright: ["Copyright (c) Stjepan Glavina"],
texts: ["mit"],
},
{
name: "fdeflate",
version: "0.3.7",
license: "MIT OR Apache-2.0",
repository: "https://github.com/image-rs/fdeflate",
copyright: ["Copyright (c) The image-rs Developers"],
texts: ["mit"],
},
{
name: "flate2",
version: "1.1.9",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/flate2-rs",
copyright: ["Copyright (c) 2014-2026 Alex Crichton"],
texts: ["mit"],
},
{
name: "foldhash",
version: "0.2.0",
license: "Zlib",
repository: "https://github.com/orlp/foldhash",
copyright: ["Copyright (c) 2024 Orson Peters"],
texts: ["zlib"],
},
{
name: "font-types",
version: "0.12.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/googlefonts/fontations",
copyright: ["Copyright (c) 2019 Fontations Developers"],
texts: ["mit"],
},
{
name: "futures-core",
version: "0.3.32",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/futures-rs",
copyright: ["Copyright (c) 2016 Alex Crichton", "Copyright (c) 2017 The Tokio Authors"],
texts: ["mit"],
},
{
name: "futures-task",
version: "0.3.32",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/futures-rs",
copyright: ["Copyright (c) 2016 Alex Crichton", "Copyright (c) 2017 The Tokio Authors"],
texts: ["mit"],
},
{
name: "futures-util",
version: "0.3.32",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/futures-rs",
copyright: ["Copyright (c) 2016 Alex Crichton", "Copyright (c) 2017 The Tokio Authors"],
texts: ["mit"],
},
{
name: "generic-array",
version: "0.14.7",
license: "MIT",
repository: "https://github.com/fizyk20/generic-array",
copyright: ["Copyright (c) 2015 Bartłomiej Kamiński"],
texts: ["mit"],
},
{
name: "getrandom",
version: "0.3.4",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-random/getrandom",
copyright: [
"Copyright (c) 2018-2025 The rust-random Project Developers",
"Copyright (c) 2014 The Rust Project Developers",
],
texts: ["mit"],
},
{
name: "half",
version: "2.7.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/VoidStarKat/half-rs",
copyright: ["Copyright (c) Kathryn Long"],
texts: ["mit"],
},
{
name: "hashbrown",
version: "0.14.5",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/hashbrown",
copyright: ["Copyright (c) 2016 Amanieu d'Antras"],
texts: ["mit"],
},
{
name: "hashbrown",
version: "0.17.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/hashbrown",
copyright: ["Copyright (c) 2016 Amanieu d'Antras"],
texts: ["mit"],
},
{
name: "indexmap",
version: "2.14.0",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/indexmap-rs/indexmap",
copyright: ["Copyright (c) 2016--2017"],
texts: ["mit"],
},
{
name: "itoa",
version: "1.0.18",
license: "MIT OR Apache-2.0",
repository: "https://github.com/dtolnay/itoa",
copyright: ["Copyright (c) David Tolnay"],
texts: ["mit"],
},
{
name: "js-sys",
version: "0.3.103",
license: "MIT OR Apache-2.0",
repository: "https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys",
copyright: ["Copyright (c) 2014 Alex Crichton"],
texts: ["mit"],
},
{
name: "libm",
version: "0.2.16",
license: "MIT",
repository: "https://github.com/rust-lang/compiler-builtins",
copyright: [
"Copyright (c) 2018 Jorge Aparicio",
"Copyright © 2005-2020 Rich Felker, et al.",
"Copyright © 1993,2004 Sun Microsystems",
"Copyright © 2003-2011 David Schultz",
"Copyright © 2003-2009 Steven G. Kargl",
"Copyright © 2003-2009 Bruce D. Evans",
"Copyright © 2008 Stephen L. Moshier",
"Copyright © 2017-2018 Arm Limited",
],
texts: ["mit-3"],
},
{
name: "lock_api",
version: "0.4.14",
license: "MIT OR Apache-2.0",
repository: "https://github.com/Amanieu/parking_lot",
copyright: ["Copyright (c) 2016 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "log",
version: "0.4.33",
license: "MIT OR Apache-2.0",
repository: "https://github.com/rust-lang/log",
copyright: ["Copyright (c) 2014 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "memchr",
version: "2.8.3",
license: "Unlicense OR MIT",
repository: "https://github.com/BurntSushi/memchr",
copyright: ["Copyright (c) 2015 Andrew Gallant"],
texts: ["mit"],
},
{
name: "miniz_oxide",
version: "0.8.9",
license: "MIT OR Zlib OR Apache-2.0",
repository: "https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide",
copyright: [
"Copyright 2013-2014 RAD Game Tools and Valve Software",
"Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC",
"Copyright (c) 2017 Frommi",
"Copyright (c) 2017-2024 oyvindln",
],
texts: ["mit"],
},
{
name: "once_cell",
version: "1.21.4",
license: "MIT OR Apache-2.0",
repository: "https://github.com/matklad/once_cell",
copyright: ["Copyright (c) Aleksey Kladov"],
texts: ["mit"],
},
{
name: "parking_lot_core",
version: "0.9.12",
license: "MIT OR Apache-2.0",
repository: "https://github.com/Amanieu/parking_lot",
copyright: ["Copyright (c) 2016 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "pin-project-lite",
version: "0.2.17",
license: "Apache-2.0 OR MIT",
repository: "https://github.com/taiki-e/pin-project-lite",
copyright: ["Copyright (c) Taiki Endo"],
texts: ["mit"],
},
{
name: "png",
version: "0.18.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/image-rs/image-png",
copyright: ["Copyright (c) 2015 nwin"],
texts: ["mit"],
},
{
name: "quick-error",
version: "2.0.1",
license: "MIT OR Apache-2.0",
repository: "http://github.com/tailhook/quick-error",
copyright: ["Copyright (c) 2015 The quick-error Developers"],
texts: ["mit"],
},
{
name: "quick-xml",
version: "0.41.0",
license: "MIT",
repository: "https://github.com/tafia/quick-xml",
copyright: ["Copyright (c) 2016 Johann Tuffe"],
texts: ["mit"],
},
{
name: "read-fonts",
version: "0.41.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/googlefonts/fontations",
copyright: ["Copyright (c) 2019 Fontations Developers"],
texts: ["mit"],
},
{
name: "rustybuzz",
version: "0.20.1",
license: "MIT",
repository: "https://github.com/harfbuzz/rustybuzz",
copyright: ["Copyright (c) HarfBuzz developers", "Copyright (c) 2020 Yevhenii Reizner"],
texts: ["mit"],
},
{
name: "ryu-js",
version: "1.0.3",
license: "Apache-2.0 OR BSL-1.0",
repository: "https://github.com/boa-dev/ryu-js",
copyright: ["Copyright (c) David Tolnay, boa-dev"],
texts: ["apache-2.0"],
},
{
name: "scopeguard",
version: "1.2.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/bluss/scopeguard",
copyright: ['Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers'],
texts: ["mit"],
},
{
name: "serde_core",
version: "1.0.228",
license: "MIT OR Apache-2.0",
repository: "https://github.com/serde-rs/serde",
copyright: ["Copyright (c) Erick Tryzelaar, David Tolnay"],
texts: ["mit"],
},
{
name: "serde_json",
version: "1.0.150",
license: "MIT OR Apache-2.0",
repository: "https://github.com/serde-rs/json",
copyright: ["Copyright (c) Erick Tryzelaar, David Tolnay"],
texts: ["mit"],
},
{
name: "serde",
version: "1.0.228",
license: "MIT OR Apache-2.0",
repository: "https://github.com/serde-rs/serde",
copyright: ["Copyright (c) Erick Tryzelaar, David Tolnay"],
texts: ["mit"],
},
{
name: "sha2",
version: "0.10.9",
license: "MIT OR Apache-2.0",
repository: "https://github.com/RustCrypto/hashes",
copyright: [
"Copyright (c) 2006-2009 Graydon Hoare",
"Copyright (c) 2009-2013 Mozilla Foundation",
"Copyright (c) 2016 Artyom Pavlov",
],
texts: ["mit"],
},
{
name: "simd-adler32",
version: "0.3.9",
license: "MIT",
repository: "https://github.com/mcountryman/simd-adler32",
copyright: ["Copyright (c) [2021] [Marvin Countryman]"],
texts: ["mit"],
},
{
name: "skrifa",
version: "0.44.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/googlefonts/fontations",
copyright: ["Copyright (c) 2019 Fontations Developers"],
texts: ["mit"],
},
{
name: "slab",
version: "0.4.12",
license: "MIT",
repository: "https://github.com/tokio-rs/slab",
copyright: ["Copyright (c) 2019 Carl Lerche"],
texts: ["mit"],
},
{
name: "smallstr",
version: "0.3.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/murarth/smallstr",
copyright: ["Copyright (c) 2018-2020 Murarth"],
texts: ["mit"],
},
{
name: "smallvec",
version: "1.15.2",
license: "MIT OR Apache-2.0",
repository: "https://github.com/servo/rust-smallvec",
copyright: ["Copyright (c) 2018 The Servo Project Developers"],
texts: ["mit"],
},
{
name: "strict-num",
version: "0.1.1",
license: "MIT",
repository: "https://github.com/RazrFalcon/strict-num",
copyright: ["Copyright (c) 2022 Yevhenii Reizner"],
texts: ["mit"],
},
{
name: "thiserror",
version: "2.0.18",
license: "MIT OR Apache-2.0",
repository: "https://github.com/dtolnay/thiserror",
copyright: ["Copyright (c) David Tolnay"],
texts: ["mit"],
},
{
name: "tiff",
version: "0.11.3",
license: "MIT",
repository: "https://github.com/image-rs/image-tiff",
copyright: ["Copyright (c) 2018 PistonDevelopers"],
texts: ["mit"],
},
{
name: "tiny-skia-path",
version: "0.12.0",
license: "BSD-3-Clause",
repository: "https://github.com/linebender/tiny-skia/tree/master/path",
copyright: [
"Copyright (c) 2011 Google Inc. All rights reserved.",
"Copyright (c) 2020 Yevhenii Reizner All rights reserved.",
],
texts: ["bsd-3-clause-2"],
},
{
name: "tiny-skia",
version: "0.12.0",
license: "BSD-3-Clause",
repository: "https://github.com/linebender/tiny-skia",
copyright: [
"Copyright (c) 2011 Google Inc. All rights reserved.",
"Copyright (c) 2020 Yevhenii Reizner All rights reserved.",
],
texts: ["bsd-3-clause-2"],
},
{
name: "ttf-parser",
version: "0.25.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/harfbuzz/ttf-parser",
copyright: ["Copyright (c) 2018 Yevhenii Reizner"],
texts: ["mit"],
},
{
name: "typed-path",
version: "0.12.3",
license: "MIT OR Apache-2.0",
repository: "https://github.com/chipsenkbeil/typed-path",
copyright: ["Copyright (c) Chip Senkbeil"],
texts: ["mit"],
},
{
name: "typenum",
version: "1.20.1",
license: "MIT OR Apache-2.0",
repository: "https://github.com/paholg/typenum",
copyright: ["Copyright (c) 2014 Paho Lurie-Gregg"],
texts: ["mit"],
},
{
name: "unicode-bidi-mirroring",
version: "0.4.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/RazrFalcon/unicode-bidi-mirroring",
copyright: ["Copyright (c) 2020 Yevhenii Reizner"],
texts: ["mit"],
},
{
name: "unicode-bidi",
version: "0.3.18",
license: "MIT OR Apache-2.0",
repository: "https://github.com/servo/unicode-bidi",
copyright: ["Copyright (c) 2015 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "unicode-ccc",
version: "0.4.0",
license: "MIT OR Apache-2.0",
repository: "https://github.com/RazrFalcon/unicode-ccc",
copyright: ["Copyright (c) 2020 Yevhenii Reizner"],
texts: ["mit"],
},
{
name: "unicode-ident",
version: "1.0.24",
license: "(MIT OR Apache-2.0) AND Unicode-3.0",
repository: "https://github.com/dtolnay/unicode-ident",
copyright: ["Copyright (c) David Tolnay", "Copyright © 1991-2023 Unicode, Inc."],
texts: ["mit", "unicode-3.0"],
},
{
name: "unicode-linebreak",
version: "0.1.5",
license: "Apache-2.0",
repository: "https://github.com/axelf4/unicode-linebreak",
copyright: ["Copyright (c) Axel Forsman"],
texts: ["apache-2.0"],
},
{
name: "unicode-properties",
version: "0.1.4",
license: "MIT OR Apache-2.0",
repository: "https://github.com/unicode-rs/unicode-properties",
copyright: ["Copyright (c) 2015 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "unicode-script",
version: "0.5.8",
license: "MIT OR Apache-2.0",
repository: "https://github.com/unicode-rs/unicode-script",
copyright: ["Copyright (c) 2019 Manish Goregaokar"],
texts: ["mit"],
},
{
name: "unicode-segmentation",
version: "1.13.3",
license: "MIT OR Apache-2.0",
repository: "https://github.com/unicode-rs/unicode-segmentation",
copyright: ["Copyright (c) 2015 The Rust Project Developers"],
texts: ["mit"],
},
{
name: "wasm-bindgen-shared",
version: "0.2.126",
license: "MIT OR Apache-2.0",
repository: "https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared",
copyright: ["Copyright (c) 2014 Alex Crichton"],
texts: ["mit"],
},
{
name: "wasm-bindgen",
version: "0.2.126",
license: "MIT OR Apache-2.0",
repository: "https://github.com/wasm-bindgen/wasm-bindgen",
copyright: ["Copyright (c) 2014 Alex Crichton"],
texts: ["mit"],
},
{
name: "weezl",
version: "0.1.12",
license: "MIT OR Apache-2.0",
repository: "https://github.com/image-rs/weezl",
copyright: ["Copyright (c) HeroicKatora 2020"],
texts: ["mit"],
},
{
name: "yrs",
version: "0.27.3",
license: "MIT",
repository: "https://github.com/y-crdt/y-crdt/",
copyright: ["Copyright (c) Kevin Jahns, Bartosz Sypytkowski"],
texts: ["mit"],
},
{
name: "zerocopy",
version: "0.8.52",
license: "BSD-2-Clause OR Apache-2.0 OR MIT",
repository: "https://github.com/google/zerocopy",
copyright: ["Copyright 2023 The Fuchsia Authors"],
texts: ["mit"],
},
{
name: "zip",
version: "8.6.0",
license: "MIT",
repository: "https://github.com/zip-rs/zip2",
copyright: ["Copyright (c) 2014 Mathijs van de Nes"],
texts: ["mit"],
},
{
name: "zlib-rs",
version: "0.6.6",
license: "Zlib",
repository: "https://github.com/trifectatechfoundation/zlib-rs",
copyright: ["(C) 2024 Trifecta Tech Foundation"],
texts: ["zlib"],
},
{
name: "zmij",
version: "1.0.21",
license: "MIT",
repository: "https://github.com/dtolnay/zmij",
copyright: ["Copyright (c) David Tolnay"],
texts: ["mit"],
},
]
export const crateTexts = [
{
id: "mit",
license: "MIT",
title: "MIT License",
text: 'Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the "Software"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.',
},
{
id: "mit-2",
license: "MIT",
title: "MIT License",
text: 'Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.',
},
{
id: "mit-3",
license: "MIT",
title: "MIT License",
text: 'rust-lang/libm as a whole is available for use under the MIT license:\n\n------------------------------------------------------------------------------\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n------------------------------------------------------------------------------\n\nAs a contributor, you agree that your code can be used under either the MIT\nlicense or the Apache-2.0 license:\n\n------------------------------------------------------------------------------\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n "License" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n "Licensor" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n "Legal Entity" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n "control" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n "You" (or "Your") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n "Source" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n "Object" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n "Work" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n "Derivative Works" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n "Contribution" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, "submitted"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as "Not a Contribution."\n\n "Contributor" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n Line truncated
},
{
id: "bsd-2-clause",
license: "BSD-2-Clause",
title: "BSD 2-Clause License",
text: 'All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n1. Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in the\n documentation and/or other materials provided with the\n distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.',
},
{
id: "bsd-3-clause",
license: "BSD-3-Clause",
title: "BSD 3-Clause License",
text: 'Redistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.',
},
{
id: "bsd-3-clause-2",
license: "BSD-3-Clause",
title: "BSD 3-Clause License",
text: 'Redistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\n notice, this list of conditions and the following disclaimer.\n\n * Redistributions in binary form must reproduce the above copyright\n notice, this list of conditions and the following disclaimer in\n the documentation and/or other materials provided with the\n distribution.\n\n * Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived\n from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.',
},
{
id: "zlib",
license: "Zlib",
title: "zlib License",
text: "This software is provided 'as-is', without any express or implied warranty. In\nno event will the authors be held liable for any damages arising from the use of\nthis software.\n\nPermission is granted to anyone to use this software for any purpose, including\ncommercial applications, and to alter it and redistribute it freely, subject to\nthe following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not claim\n that you wrote the original software. If you use this software in a product,\n an acknowledgment in the product documentation would be appreciated but is\n not required.\n\n2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n\n3. This notice may not be removed or altered from any source distribution.",
},
{
id: "unicode-3.0",
license: "Unicode-3.0",
title: "Unicode License v3",
text: 'NOTICE TO USER: Carefully read the following legal agreement. BY\nDOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of data files and any associated documentation (the "Data Files") or\nsoftware and any associated documentation (the "Software") to deal in the\nData Files or Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, and/or sell\ncopies of the Data Files or Software, and to permit persons to whom the\nData Files or Software are furnished to do so, provided that either (a)\nthis copyright and permission notice appear with all copies of the Data\nFiles or Software, or (b) this copyright and permission notice appear in\nassociated Documentation.\n\nTHE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY\nKIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained in this notice, the name of a copyright holder shall\nnot be used in advertising or otherwise to promote the sale, use or other\ndealings in these Data Files or Software without prior written\nauthorization of the copyright holder.',
},
]
@@ -1,107 +0,0 @@
import { For, Show } from "solid-js"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup, type DialogProps } from "@opencode/ui/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
import { fonts } from "./fonts"
import { crateNotices, notices } from "./notices"
const licenseOf = new Map(crateNotices.texts.map((text) => [text.id, text.license]))
const texts = crateNotices.texts.map((text) => ({
...text,
// Two versions of one crate (hashbrown) read as one name.
crates: [...new Set(crateNotices.crates.filter((crate) => crate.texts.includes(text.id)).map((crate) => crate.name))],
}))
export default function DialogThirdPartyNotices(props: { onCloseAutoFocus?: DialogProps["onCloseAutoFocus"] }) {
const language = useLanguage()
return (
<Dialog size="large" onCloseAutoFocus={props.onCloseAutoFocus}>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.about.notices.title")}
description={language.t("settings.about.notices.description")}
/>
</DialogHeader>
<DialogBody class="settings-notices">
<For each={notices}>
{(entry) => (
<section class="settings-notice">
<div class="settings-notice-heading">
<ExternalLink href={entry.url}>{entry.name}</ExternalLink>
<span>{entry.license}</span>
</div>
<p>{entry.detailKey ? language.t(entry.detailKey) : entry.detail}</p>
<Show when={entry.notice}>{(notice) => <pre>{notice()}</pre>}</Show>
<details>
<summary>{language.t("settings.about.notices.license")}</summary>
<pre>{entry.text}</pre>
</details>
</section>
)}
</For>
<section class="settings-notice settings-crates">
<h3 class="settings-notice-heading">{language.t("settings.about.notices.fonts.title")}</h3>
<p>{language.t("settings.about.notices.fonts.description")}</p>
<ul class="settings-crate-list">
<For each={fonts.families}>
{(family) => (
<li class="settings-crate">
<div class="settings-crate-heading">
<ExternalLink href={family.url}>{family.name}</ExternalLink>
</div>
<p>{family.copyright.join("\n")}</p>
</li>
)}
</For>
</ul>
<details>
<summary>SIL Open Font License 1.1</summary>
<pre>{fonts.license}</pre>
</details>
</section>
<section class="settings-notice settings-crates">
<h3 class="settings-notice-heading">{language.t("settings.about.notices.crates.title")}</h3>
<p>{language.t("settings.about.notices.crates.description")}</p>
<ul class="settings-crate-list">
<For each={crateNotices.crates}>
{(crate) => {
const applied = new Set(crate.texts.map((id) => licenseOf.get(id)))
return (
<li class="settings-crate">
<div class="settings-crate-heading">
<span>
<ExternalLink href={crate.repository}>{crate.name}</ExternalLink> {crate.version}
</span>
<span>
<For each={crate.license.split(/([\s()]+)/)}>
{(part) => (applied.has(part) ? <strong>{part}</strong> : part)}
</For>
</span>
</div>
<p>{crate.copyright.join("\n")}</p>
</li>
)
}}
</For>
</ul>
<h4>{language.t("settings.about.notices.crates.licenses")}</h4>
<For each={texts}>
{(text) => (
<details>
<summary>
{text.title}
<span>{language.plural("settings.about.notices.crates.count", text.crates.length)}</span>
</summary>
<p>{language.rich("settings.about.notices.crates.appliesTo", { names: language.list(text.crates) })}</p>
<pre>{text.text}</pre>
</details>
)}
</For>
</section>
</DialogBody>
</Dialog>
)
}
@@ -1,107 +0,0 @@
import license from "./fonts/OFL-1.1.txt?raw"
/**
* The font families the Office previews ship, each with the copyright lines its own license file carries. Every
* family is under the SIL Open Font License 1.1, so the license text is shown once.
*/
export const fonts = {
license,
families: [
{
name: "Carlito",
url: "https://github.com/googlefonts/carlito",
copyright: [
'Copyright 2013 The Carlito Project Authors (https://github.com/googlefonts/carlito), with Reserved Font Name "Carlito"',
],
},
{
name: "Caladea",
url: "https://github.com/huertatipografica/Caladea",
copyright: ["Copyright 2012 The Caladea Project Authors (https://github.com/huertatipografica/Caladea)"],
},
{
name: "Liberation Sans, Liberation Serif, Liberation Mono",
url: "https://github.com/liberationfonts/liberation-fonts",
copyright: [
"Digitized data copyright (c) 2010 Google Corporation",
"with Reserved Font Arimo, Tinos and Cousine.",
"Copyright (c) 2012 Red Hat, Inc.",
"with Reserved Font Name Liberation.",
],
},
{
name: "Gelasio",
url: "https://github.com/SorkinType/Gelasio",
copyright: ["Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio)"],
},
{
name: "Comic Relief",
url: "https://github.com/loudifier/Comic-Relief",
copyright: ["Copyright 2013 The Comic Relief Project Authors (https://github.com/loudifier/Comic-Relief)"],
},
{
name: "Inter",
url: "https://github.com/rsms/inter",
copyright: ["Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)"],
},
{
name: "Roboto",
url: "https://github.com/googlefonts/roboto-classic",
copyright: ["Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)"],
},
{
name: "Source Sans 3",
url: "https://github.com/adobe-fonts/source-sans",
copyright: [
"Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.",
],
},
{
name: "DM Sans",
url: "https://github.com/googlefonts/dm-fonts",
copyright: ["Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)"],
},
{
name: "DM Serif Display",
url: "https://github.com/googlefonts/dm-fonts",
copyright: [
"Copyright 2014-2018 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. Copyright 2019 Google LLC.",
],
},
{
name: "Open Sans",
url: "https://github.com/googlefonts/opensans",
copyright: ["Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)"],
},
{
name: "Montserrat",
url: "https://github.com/JulietaUla/Montserrat",
copyright: ["Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git)"],
},
{
name: "Poppins",
url: "https://github.com/itfoundry/Poppins",
copyright: ["Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)"],
},
{
name: "Oswald",
url: "https://github.com/googlefonts/OswaldFont",
copyright: ["Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont)"],
},
{
name: "Heebo",
url: "https://github.com/OdedEzer/heebo",
copyright: ["Copyright 2014 The Heebo Project Authors (https://github.com/OdedEzer/heebo)"],
},
{
name: "Noto Sans Arabic, Noto Naskh Arabic",
url: "https://github.com/notofonts/arabic",
copyright: ["Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)"],
},
{
name: "Noto Sans Hebrew",
url: "https://github.com/notofonts/hebrew",
copyright: ["Copyright 2022 The Noto Project Authors (https://github.com/notofonts/hebrew)"],
},
],
}
@@ -1,91 +0,0 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,37 +0,0 @@
import apache from "./apache-2.0.txt?raw"
import betterOfficeNotice from "./betteroffice-notice.txt?raw"
import { crates, crateTexts } from "./crates"
/**
* Third-party software that ships inside the app, with the attribution its license asks redistributions to carry.
* Notices and license texts are reproduced verbatim from each project, so they are not translated.
*/
export const notices = [
{
name: "BetterOffice",
detail: "@betteroffice/docx, @betteroffice/xlsx, @betteroffice/pptx, @betteroffice/fonts",
detailKey: undefined,
url: "https://github.com/openooxml/betteroffice",
license: "Apache License 2.0",
notice: `${betterOfficeNotice.trim()}\nCopyright 2026 The OpenOOXML contributors`,
text: apache,
},
{
name: "eigenpal docx editor",
detail: undefined,
detailKey: "settings.about.notices.eigenpal" as const,
url: "https://github.com/eigenpal/docx-editor",
license: "Apache License 2.0",
notice: "Copyright 2026 EigenPal Inc.",
text: apache,
},
]
/**
* The Rust crates the BetterOffice wasm engines compile in, generated from the published Cargo.lock and crate
* tarballs. Each crate keeps its own copyright lines; license texts are stored once and referenced by id.
*/
export const crateNotices = {
crates,
texts: [...crateTexts, { id: "apache-2.0", license: "Apache-2.0", title: "Apache License 2.0", text: apache }],
}
+1 -13
View File
@@ -1,5 +1,4 @@
import { Badge } from "@opencode/ui/badge"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { createMemo, Show, type Component } from "solid-js"
import { ServerRowMenu } from "@/servers/registry/row-menu"
@@ -30,10 +29,6 @@ export const SettingsServerGeneral: Component<{
const edit = (server: ServerConnection.Http) =>
void dialog.push(() => <DialogServer mode="edit" server={server} onSave={props.onServerChange} />)
// An HTTP server that rejects its saved credentials signs in again through the edit dialog.
const signedOut = (server: ServerConnection.Any) =>
server.type === "http" && health()?.unauthorized ? server : undefined
return (
<>
<div class="settings-tab-header">
@@ -64,7 +59,7 @@ export const SettingsServerGeneral: Component<{
{(server) => (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} authenticationRequired={!!health()?.unauthorized} />
<ServerHealthIndicator health={health()} />
<div class="settings-servers-copy">
<bdi class="settings-servers-name" dir="auto">
{serverName(server()) || props.entry.key}
@@ -75,13 +70,6 @@ export const SettingsServerGeneral: Component<{
</div>
</div>
<div class="settings-servers-actions">
<Show when={signedOut(server())}>
{(http) => (
<Button size="small" variant="neutral" onClick={() => edit(http())}>
{language.t("server.action.authenticate")}
</Button>
)}
</Show>
<Show when={controller.defaults.available() && controller.defaults.key() === props.entry.key}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
+1 -167
View File
@@ -289,152 +289,6 @@
text-decoration: underline;
}
.settings-about-link {
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
font: inherit;
letter-spacing: inherit;
}
.settings-about-link:hover,
.settings-about-link:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.settings-notices {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0 20px 20px;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
.settings-notice {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-notice p {
margin: 0;
}
.settings-notice-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: var(--v2-text-text-base);
font-weight: 540;
}
.settings-notice-heading span {
color: var(--v2-text-text-faint);
font-weight: 440;
}
.settings-notice-heading a {
color: inherit;
}
.settings-notice summary {
cursor: pointer;
color: var(--v2-text-text-faint);
}
.settings-notice pre {
margin: 0;
font-family: var(--font-family-mono);
font-size: 11px;
line-height: var(--line-height-compact);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.settings-notice details pre {
margin-top: 8px;
padding: 12px;
border-radius: 8px;
background: var(--v2-background-bg-layer-02);
}
.settings-crates {
padding-top: 20px;
border-top: 0.5px solid var(--v2-border-border-base);
}
.settings-crates h3,
.settings-crates h4 {
margin: 0;
font-size: 13px;
line-height: var(--line-height-base);
}
.settings-crates h4 {
margin-top: 8px;
color: var(--v2-text-text-base);
font-weight: 540;
}
.settings-crate-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.settings-crate {
line-height: var(--line-height-compact);
}
.settings-crate-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.settings-crate-heading > span:first-child {
color: var(--v2-text-text-faint);
}
.settings-crate-heading a {
color: var(--v2-text-text-base);
}
.settings-crate-heading > span:last-child {
flex-shrink: 0;
color: var(--v2-text-text-faint);
}
.settings-crate-heading strong {
color: var(--v2-text-text-base);
font-weight: 540;
}
.settings-crate p {
color: var(--v2-text-text-faint);
font-size: 12px;
white-space: pre-line;
overflow-wrap: anywhere;
}
.settings-crates summary span {
margin-inline-start: 8px;
}
.settings-crates details > p {
margin-top: 8px;
}
.settings-back {
display: flex;
flex-shrink: 0;
@@ -1955,8 +1809,7 @@
.settings-server-dialog-error {
font-size: 11px;
font-weight: 440;
/* Pairing errors are full sentences that wrap. */
line-height: var(--line-height-compact);
line-height: 1;
color: var(--v2-state-fg-danger);
}
@@ -1966,25 +1819,6 @@
color: var(--v2-text-text-muted);
}
.settings-server-dialog-notice {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 10px 12px;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background: var(--v2-background-bg-layer-01);
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-base);
}
.settings-server-dialog-notice [data-slot="icon-svg"] {
flex: none;
margin-block: 2px;
color: var(--v2-icon-icon-muted);
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: min(280px, 100%);
}
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more