mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 03:22:13 +00:00
tui: preserve selection after copy (#44496)
Copy-on-select reset the click counter, so a third click could not select the full line. Keep the selection after copying to preserve multi-click input.
This commit is contained in:
parent
b22c182406
commit
0d24ebdbbe
5 changed files with 130 additions and 15 deletions
|
|
@ -1282,7 +1282,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
onMouseUp={
|
||||
copyOnSelectEnabled() ? (event) => Selection.copyOnSelectRelease(event, renderer, toast, clipboard) : undefined
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createStore } from "solid-js/store"
|
|||
import { useToast } from "./toast"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useConfig } from "../config"
|
||||
import { copy, copyOnSelectRelease } from "../util/selection"
|
||||
|
||||
export type DialogSize = "medium" | "large" | "xlarge"
|
||||
|
||||
|
|
@ -210,17 +211,6 @@ export function DialogProvider(props: ParentProps) {
|
|||
const copyOnSelectEnabled = () =>
|
||||
(config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select"
|
||||
|
||||
function copySelection() {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (!text) return false
|
||||
void clipboard.write(text).then(
|
||||
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
renderer.clearSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
|
|
@ -231,11 +221,11 @@ export function DialogProvider(props: ParentProps) {
|
|||
if (copyOnSelectEnabled()) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
if (!copySelection()) return
|
||||
if (!copy(renderer, toast, clipboard)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
onMouseUp={copyOnSelectEnabled() ? copySelection : undefined}
|
||||
onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ type SelectionKeyEvent = {
|
|||
stopPropagation: () => void
|
||||
}
|
||||
|
||||
export function copyOnSelectRelease(
|
||||
event: { isDragging?: boolean },
|
||||
renderer: Renderer,
|
||||
toast: Toast,
|
||||
clipboard: ClipboardService,
|
||||
): boolean {
|
||||
if (!event.isDragging) return false
|
||||
return copy(renderer, toast, clipboard)
|
||||
}
|
||||
|
||||
export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean {
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection) return false
|
||||
|
|
@ -39,7 +49,8 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi
|
|||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
|
||||
renderer.clearSelection()
|
||||
// Keep the highlight. clearSelection() also resets OpenTUI's click
|
||||
// counter, so clearing here would turn a triple-click into a new single-click.
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
59
packages/tui/test/util/selection-copy-on-select.test.tsx
Normal file
59
packages/tui/test/util/selection-copy-on-select.test.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { useClipboard } from "../../src/context/clipboard"
|
||||
import { copyOnSelectRelease } from "../../src/util/selection"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
|
||||
function CopyOnSelectText() {
|
||||
const renderer = useRenderer()
|
||||
const clipboard = useClipboard()
|
||||
const toast = {
|
||||
show: () => {},
|
||||
error: () => {},
|
||||
}
|
||||
return (
|
||||
<box onMouseUp={(event) => copyOnSelectRelease(event, renderer, toast, clipboard)}>
|
||||
<text>alpha beta gamma</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
test("copy-on-select keeps a word highlight so a third click can select the line", async () => {
|
||||
const writes: string[] = []
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts
|
||||
clipboard={{
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write(text) {
|
||||
writes.push(text)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CopyOnSelectText />
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("beta"))
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("")
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
||||
expect(writes).toEqual(["beta"])
|
||||
|
||||
await app.mockMouse.click(6, 0)
|
||||
expect(app.renderer.getSelection()?.getSelectedText()).toBe("alpha beta gamma")
|
||||
expect(writes).toEqual(["beta", "alpha beta gamma"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
53
packages/tui/test/util/selection.test.ts
Normal file
53
packages/tui/test/util/selection.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { copy, copyOnSelectRelease } from "../../src/util/selection"
|
||||
|
||||
function renderer() {
|
||||
return {
|
||||
getSelection: () => ({
|
||||
getSelectedText: () => "beta",
|
||||
selectedRenderables: [],
|
||||
}),
|
||||
clearSelection: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
test("copy writes selected text without clearing the highlight", () => {
|
||||
let cleared = false
|
||||
const copied = copy(
|
||||
{
|
||||
getSelection: () => ({
|
||||
getSelectedText: () => "beta",
|
||||
selectedRenderables: [],
|
||||
}),
|
||||
clearSelection: () => {
|
||||
cleared = true
|
||||
},
|
||||
},
|
||||
{ show: () => {}, error: () => {} },
|
||||
{
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write() {},
|
||||
},
|
||||
)
|
||||
expect(copied).toBe(true)
|
||||
expect(cleared).toBe(false)
|
||||
})
|
||||
|
||||
test("copy-on-select ignores a later non-drag release", () => {
|
||||
const writes: string[] = []
|
||||
const clipboard = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write(value: string) {
|
||||
writes.push(value)
|
||||
},
|
||||
}
|
||||
const toast = { show: () => {}, error: () => {} }
|
||||
expect(copyOnSelectRelease({}, renderer(), toast, clipboard)).toBe(false)
|
||||
expect(copyOnSelectRelease({ isDragging: false }, renderer(), toast, clipboard)).toBe(false)
|
||||
expect(copyOnSelectRelease({ isDragging: true }, renderer(), toast, clipboard)).toBe(true)
|
||||
expect(writes).toEqual(["beta"])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue