mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): restore branch context in new session
This commit is contained in:
@@ -51,6 +51,90 @@ test("the new session screen does not show session details", async ({ page }) =>
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("the combined location picker shows checkout branches and chooses the base for a new worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const listed = Promise.withResolvers<string[]>()
|
||||
const searches: { directory: string; search?: string; limit?: number }[] = []
|
||||
const existing = "C:/OpenCode/BranchContext/.worktrees/stellar-orchid"
|
||||
|
||||
const view = await openWorktreeDraft(page, {
|
||||
name: "BranchContext",
|
||||
project: { name: "OpenCode" },
|
||||
vcs: (directory) => ({
|
||||
current: directory === existing ? "model-picker-dialog" : "timeline-quotes",
|
||||
default: "main",
|
||||
}),
|
||||
worktrees: () => [{ directory: "C:/OpenCode/BranchContext" }, { directory: existing, strategy: "git" }],
|
||||
branches: (input) => {
|
||||
searches.push(input)
|
||||
|
||||
return listed.promise
|
||||
},
|
||||
})
|
||||
|
||||
const screen = page.locator('[data-component="new-session"]')
|
||||
const base = screen.getByRole("button", { name: "from timeline-quotes", exact: true })
|
||||
await expect(base).toBeVisible()
|
||||
expect(searches).toEqual([])
|
||||
await base.click()
|
||||
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
await expect(page.getByText("Loading", { exact: true })).toBeVisible()
|
||||
await expect(newSessionLocation(page)).toBeVisible()
|
||||
listed.resolve(["timeline-quotes", "release/base", "origin/dev"])
|
||||
await expect(page.getByRole("menuitemradio", { name: "release/base", exact: true })).toBeVisible()
|
||||
await search.fill("release")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["release/base"])
|
||||
await page.getByRole("menuitemradio", { name: "release/base", exact: true }).click()
|
||||
const selected = screen.getByRole("button", { name: "from release/base", exact: true })
|
||||
await expect(selected).toBeVisible()
|
||||
await expect(view.editor).toBeFocused()
|
||||
expect(searches).toEqual([
|
||||
{ directory: view.directory, search: "", limit: 50 },
|
||||
{ directory: view.directory, search: "release", limit: 50 },
|
||||
])
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const location = await newSessionLocation(page).boundingBox()
|
||||
const branch = await selected.boundingBox()
|
||||
|
||||
return !!location && !!branch && branch.y >= location.y + location.height
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const select = async (name: RegExp) => {
|
||||
await newSessionLocation(page).click()
|
||||
await page.getByRole("textbox", { name: "Search projects", exact: true }).press("ArrowRight")
|
||||
await page.getByRole("option", { name }).click()
|
||||
await expect(view.editor).toBeFocused()
|
||||
}
|
||||
|
||||
await select(/^stellar-orchid /)
|
||||
await expect(screen.getByText("model-picker-dialog", { exact: true })).toBeVisible()
|
||||
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
|
||||
await select(/^BranchContext /)
|
||||
await expect(screen.getByText("timeline-quotes", { exact: true })).toBeVisible()
|
||||
await expect(screen.getByRole("button", { name: /from / })).toHaveCount(0)
|
||||
await select(/^New worktree/)
|
||||
await expect(base).toBeVisible()
|
||||
await base.click()
|
||||
await expect(search).toBeFocused()
|
||||
await page.getByRole("menuitemradio", { name: "origin/dev", exact: true }).click()
|
||||
await expect(screen.getByRole("button", { name: "from origin/dev", exact: true })).toBeVisible()
|
||||
await view.editor.fill("Use the selected base")
|
||||
await submit(page).click()
|
||||
await expect
|
||||
.poll(() => view.worktreeRequests.map((request) => request.body))
|
||||
.toEqual([expect.objectContaining({ from: view.directory, branch: "origin/dev" })])
|
||||
view.worktree.resolve({ status: 200, json: { directory: existing } })
|
||||
await expect
|
||||
.poll(() => view.prompts)
|
||||
.toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })])
|
||||
})
|
||||
|
||||
test("the dark new session panel exposes no lighter background at its rounded corners", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 935, height: 522 })
|
||||
await openDraft(page, { name: "PanelCorner", seed: { theme: { id: "oc-2", scheme: "dark" } } })
|
||||
|
||||
@@ -82,7 +82,8 @@ export interface MockServerConfig {
|
||||
items: SessionMessageInfo[]
|
||||
cursor?: string
|
||||
}
|
||||
vcs?: { current: string; default: string }
|
||||
vcs?: { current: string; default: string } | ((directory: string) => { current: string; default: string })
|
||||
branches?: (input: { directory: string; search?: string; limit?: number }) => string[] | Promise<string[]>
|
||||
// Initializes the mock project's VCS; without a handler this mutation answers 501.
|
||||
onVcsInit?: (input: { directory: string; provider?: string }) => void
|
||||
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
|
||||
@@ -1018,10 +1019,14 @@ function mockHandlers(
|
||||
}),
|
||||
),
|
||||
formRequests: () => Effect.succeed({ location: location(config), data: resolve(config.forms ?? []) }),
|
||||
vcs: () =>
|
||||
vcs: (ctx) =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: { branch: config.vcs ?? { current: "main", default: "main" } },
|
||||
location: location(config, requestDirectory(config, ctx.request)),
|
||||
data: {
|
||||
branch: Predicate.isFunction(config.vcs)
|
||||
? config.vcs(requestDirectory(config, ctx.request))
|
||||
: (config.vcs ?? { current: "main", default: "main" }),
|
||||
},
|
||||
}),
|
||||
vcsInit: (ctx) => {
|
||||
if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit")
|
||||
@@ -1044,7 +1049,26 @@ function mockHandlers(
|
||||
}).pipe(Effect.andThen(noContent))
|
||||
},
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }),
|
||||
vcsBranches: (ctx) => {
|
||||
const query = new URL(ctx.request.url, "http://localhost").searchParams
|
||||
const search = query.get("search") ?? ""
|
||||
const limit = Number(query.get("limit") ?? 50)
|
||||
|
||||
return Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
config.branches?.({
|
||||
directory: requestDirectory(config, ctx.request),
|
||||
search,
|
||||
limit,
|
||||
}) ?? ["main"],
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((items) => ({
|
||||
location: location(config, requestDirectory(config, ctx.request)),
|
||||
data: items.filter((branch) => branch.toLowerCase().includes(search.toLowerCase())).slice(0, limit),
|
||||
})),
|
||||
)
|
||||
},
|
||||
vcsDiff: (ctx) =>
|
||||
Effect.sync(() => ({
|
||||
location: location(config),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Show, createMemo, createSignal, type JSX } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionBranch } from "./workspace/selector"
|
||||
import { NewSessionPinBoard } from "./pin-board"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
|
||||
@@ -85,6 +86,7 @@ export function NewSessionView(props: {
|
||||
project={project()}
|
||||
workspace={props.workspace.bar.visible() ? props.workspace.selection.value() : "main"}
|
||||
root={props.workspace.project.root()}
|
||||
branch={<NewSessionBranch workspace={props.workspace} onDone={props.composer.restoreFocus} />}
|
||||
onboarding={onboardingReady() && !onboarding.used && props.workspace.bar.visible()}
|
||||
onClick={() =>
|
||||
props.location({
|
||||
@@ -198,12 +200,13 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"];
|
||||
)
|
||||
}
|
||||
|
||||
/** The tray under the Composer: the location picker on the left and the session's directory on the right. */
|
||||
/** The composer tray: combined location picker, Git context, and the session's directory. */
|
||||
function NewSessionLocation(props: {
|
||||
project: PromptProject
|
||||
/** "main", "create", or a worktree directory. */
|
||||
workspace: string
|
||||
root: string
|
||||
branch?: JSX.Element
|
||||
onboarding: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
@@ -225,8 +228,8 @@ function NewSessionLocation(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex h-9 min-w-0 items-center gap-3 ps-2 pe-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
|
||||
<div class="flex min-w-0 max-w-[60%] shrink-0 @max-[560px]:max-w-full">
|
||||
<div class="flex min-h-9 min-w-0 items-center gap-3 ps-2 pe-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
|
||||
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-x-3 gap-y-1 py-1.5">
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
@@ -265,7 +268,9 @@ function NewSessionLocation(props: {
|
||||
<Show when={worktree()}>
|
||||
{(name) => (
|
||||
<>
|
||||
<Icon name="chevron-right" class="-mx-1 shrink-0 text-v2-icon-icon-faint" />
|
||||
<span class="shrink-0 ps-1 pe-0.5 text-[11px] font-medium text-v2-text-text-faint" aria-hidden="true">
|
||||
/
|
||||
</span>
|
||||
<span class="min-w-12 shrink-[1000] truncate">{name()}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -279,6 +284,7 @@ function NewSessionLocation(props: {
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{props.branch}
|
||||
</div>
|
||||
<Show when={path()}>
|
||||
{(directory) => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -61,9 +62,14 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const tabs = useTabs()
|
||||
|
||||
const [state, setState] = createStore({
|
||||
branchesOpen: false,
|
||||
search: "",
|
||||
existing: undefined as { projectID: string; directory: string } | undefined,
|
||||
})
|
||||
|
||||
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
|
||||
onCleanup(searchBranches.clear)
|
||||
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
@@ -170,6 +176,27 @@ export function createNewSessionWorkspaceController(input: {
|
||||
)
|
||||
|
||||
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
|
||||
const branchDirectory = () => (value() === "create" ? projectRoot() : sdk().directory)
|
||||
|
||||
const [branches, branchActions] = createResource(
|
||||
() =>
|
||||
state.branchesOpen && value() === "create" && visible()
|
||||
? { directory: projectRoot(), search: state.search }
|
||||
: undefined,
|
||||
async (source) =>
|
||||
serverSDK.api.vcs.branch
|
||||
.list({ location: { directory: source.directory }, search: source.search, limit: 50 })
|
||||
.then((response) => ({ ...source, items: response.data, failed: false }))
|
||||
.catch(() => ({ ...source, items: [], failed: true })),
|
||||
)
|
||||
|
||||
// Branch search must not suspend the draft or expose another project's previous results.
|
||||
const branchResults = () => {
|
||||
if (branches.state !== "ready" && branches.state !== "refreshing") return
|
||||
const loaded = branches.latest
|
||||
|
||||
return loaded?.directory === projectRoot() && loaded.search === state.search ? loaded : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void Promise.all([
|
||||
@@ -178,6 +205,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
data.location.vcs.sync({ directory: sdk().directory }),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (value() !== "create" || sameDirectory(projectRoot(), sdk().directory)) return
|
||||
void data.location.vcs.sync({ directory: projectRoot() }).catch(() => undefined)
|
||||
})
|
||||
// Only the selected worktree feeds the branch label. Syncing every worktree in the inventory boots
|
||||
// each one on the server, which then emits `agent.updated` and makes the client run the full
|
||||
// catalog fan-out for every directory.
|
||||
@@ -194,7 +225,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
directory: sdk().directory,
|
||||
directory: branchDirectory(),
|
||||
createBranch: input.selectedBranch(),
|
||||
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
||||
}),
|
||||
@@ -249,6 +280,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
},
|
||||
remember,
|
||||
set: select,
|
||||
branch: (branch: string) => input.setSelectedBranch(branch),
|
||||
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
|
||||
},
|
||||
project: {
|
||||
@@ -261,6 +293,17 @@ export function createNewSessionWorkspaceController(input: {
|
||||
visible,
|
||||
branch,
|
||||
},
|
||||
branches: {
|
||||
items: () => branchResults()?.items ?? [],
|
||||
loading: () => branches.loading,
|
||||
failed: () => branchResults()?.failed ?? false,
|
||||
search: searchBranches,
|
||||
open: (open: boolean) => {
|
||||
searchBranches.clear()
|
||||
setState({ branchesOpen: open, search: "" })
|
||||
},
|
||||
retry: () => branchActions.refetch(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { NewSessionWorkspaceController } from "./controller"
|
||||
|
||||
export function NewSessionBranch(props: { workspace: NewSessionWorkspaceController; onDone: () => void }) {
|
||||
const language = useLanguage()
|
||||
|
||||
const [state, setState] = createStore<{ search: string; truncated: boolean; pending?: string }>({
|
||||
search: "",
|
||||
truncated: false,
|
||||
})
|
||||
|
||||
let input: HTMLInputElement | undefined
|
||||
const creating = () => props.workspace.selection.value() === "create"
|
||||
|
||||
const label = () =>
|
||||
creating()
|
||||
? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" })
|
||||
: props.workspace.bar.branch()
|
||||
|
||||
const observe = (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth))
|
||||
|
||||
return (
|
||||
<Show when={props.workspace.bar.branch()}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={label()}
|
||||
disabled={!state.truncated}
|
||||
class="flex min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Show
|
||||
when={creating()}
|
||||
fallback={
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 text-v2-text-text-faint">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Menu
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
onOpenChange={(open) => {
|
||||
props.workspace.branches.open(open)
|
||||
|
||||
if (open) {
|
||||
setState({ search: "", pending: undefined })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (state.pending) props.workspace.selection.branch(state.pending)
|
||||
setState("pending", undefined)
|
||||
props.onDone()
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Kobalte schedules list autofocus after the focus scope opens.
|
||||
setTimeout(() => requestAnimationFrame(() => input?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm ps-3 pe-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={input}
|
||||
value={state.search}
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
autocomplete="off"
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => {
|
||||
setState("search", event.currentTarget.value)
|
||||
props.workspace.branches.search(event.currentTarget.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (["Escape", "ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<Show when={state.search.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-sm text-v2-icon-icon-faint hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base"
|
||||
aria-label={language.t("common.clear")}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setState("search", "")
|
||||
props.workspace.branches.search("")
|
||||
input?.focus()
|
||||
}}
|
||||
>
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<Show
|
||||
when={!props.workspace.branches.failed()}
|
||||
fallback={
|
||||
<Menu.Item closeOnSelect={false} onSelect={() => void props.workspace.branches.retry()}>
|
||||
{language.t("common.retry")}
|
||||
</Menu.Item>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.workspace.branches.items().length > 0}
|
||||
fallback={
|
||||
<div class="px-3 py-2 text-v2-text-text-faint">
|
||||
{language.t(props.workspace.branches.loading() ? "common.loading" : "palette.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Menu.RadioGroup value={props.workspace.bar.branch()}>
|
||||
<For each={props.workspace.branches.items()}>
|
||||
{(branch) => (
|
||||
<Menu.RadioItem
|
||||
value={branch}
|
||||
closeOnSelect
|
||||
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
onSelect={() => setState("pending", branch)}
|
||||
>
|
||||
<span dir="auto" class="min-w-0 truncate leading-5">
|
||||
{branch}
|
||||
</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -587,6 +587,8 @@ export const dict = {
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
"session.new.workspace.existingLabel": "Worktree",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.branch.search.placeholder": "Search branches",
|
||||
"session.new.git.none": "No Git",
|
||||
|
||||
"session.search.placeholder": "Find...",
|
||||
|
||||
Reference in New Issue
Block a user