Fix copying file contents in the viewer (#423)

* Fix copying file contents in viewer (#421)

* fix(ui): scope file copy shortcuts and support WebKit

* ci: verify committed UI bundle matches source

---------

Co-authored-by: Daniel Kim <sox8502@gmail.com>
This commit is contained in:
Aleksandr Rodionov
2026-09-23 19:31:56 -07:00
committed by GitHub
co-authored by Daniel Kim
parent de469a3f44
commit fe8db9f48b
11 changed files with 305 additions and 1354 deletions
+11
View File
@@ -65,6 +65,17 @@ jobs:
- name: UI unit tests
run: node --test --experimental-strip-types ui/tests/*.test.mjs
- name: Committed UI bundle matches source
working-directory: ui
run: |
pnpm exec vite build
stale=$(git status --porcelain -- dist)
if [ -n "$stale" ]; then
echo "$stale"
echo "::error::ui/dist is stale; run pnpm build in ui/ and commit the result."
exit 1
fi
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
-1123
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -49,7 +49,7 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-CTdE8kO2.js"></script>
<script type="module" crossorigin src="/assets/index-yT9oNk64.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_9P_q05.css">
</head>
<body>
+2
View File
@@ -176,6 +176,8 @@
"experiments_table_stop_failed": "فشل الإيقاف:",
"file_viewer_binary_file_no_inline_preview": "ملف ثنائي — لا توجد معاينة مضمّنة.",
"file_viewer_copy_install_command": "نسخ أمر التثبيت",
"file_viewer_copy_contents": "نسخ محتوى الملف",
"file_viewer_copy_failed": "تعذّر نسخ محتوى الملف",
"file_viewer_discard_my_edits_and_reload": "تجاهل تعديلاتي وإعادة التحميل",
"file_viewer_dismiss": "تجاهل",
"file_viewer_dismiss_compile_message": "تجاهل رسالة التجميع",
+2
View File
@@ -176,6 +176,8 @@
"experiments_table_stop_failed": "Stop failed:",
"file_viewer_binary_file_no_inline_preview": "Binary file — no inline preview.",
"file_viewer_copy_install_command": "Copy install command",
"file_viewer_copy_contents": "Copy file contents",
"file_viewer_copy_failed": "Could not copy file contents",
"file_viewer_discard_my_edits_and_reload": "Discard my edits and reload",
"file_viewer_dismiss": "Dismiss",
"file_viewer_dismiss_compile_message": "Dismiss compile message",
+2
View File
@@ -176,6 +176,8 @@
"experiments_table_stop_failed": "Error al detener:",
"file_viewer_binary_file_no_inline_preview": "Archivo binario — sin vista previa.",
"file_viewer_copy_install_command": "Copiar comando de instalación",
"file_viewer_copy_contents": "Copiar contenido del archivo",
"file_viewer_copy_failed": "No se pudo copiar el contenido del archivo",
"file_viewer_discard_my_edits_and_reload": "Descartar mis cambios y recargar",
"file_viewer_dismiss": "Cerrar",
"file_viewer_dismiss_compile_message": "Cerrar mensaje de compilación",
+2
View File
@@ -176,6 +176,8 @@
"experiments_table_stop_failed": "توقف ناموفق بود:",
"file_viewer_binary_file_no_inline_preview": "فایل دودویی است — پیش‌نمایش درون‌خطی ندارد.",
"file_viewer_copy_install_command": "کپی فرمان نصب",
"file_viewer_copy_contents": "کپی محتوای فایل",
"file_viewer_copy_failed": "کپی محتوای فایل ممکن نشد",
"file_viewer_discard_my_edits_and_reload": "نادیده گرفتن ویرایش‌های من و بارگیری دوباره",
"file_viewer_dismiss": "بستن",
"file_viewer_dismiss_compile_message": "بستن پیام کامپایل",
+2
View File
@@ -176,6 +176,8 @@
"experiments_table_stop_failed": "रोकना विफल:",
"file_viewer_binary_file_no_inline_preview": "बाइनरी फ़ाइल — इनलाइन प्रीव्यू नहीं है।",
"file_viewer_copy_install_command": "इंस्टॉल कमांड कॉपी करें",
"file_viewer_copy_contents": "फ़ाइल की सामग्री कॉपी करें",
"file_viewer_copy_failed": "फ़ाइल की सामग्री कॉपी नहीं हो सकी",
"file_viewer_discard_my_edits_and_reload": "मेरे बदलाव छोड़ें और फिर से लोड करें",
"file_viewer_dismiss": "खारिज करें",
"file_viewer_dismiss_compile_message": "कंपाइल संदेश खारिज करें",
+2
View File
@@ -176,6 +176,8 @@
"experiments_table_stop_failed": "停止失败:",
"file_viewer_binary_file_no_inline_preview": "二进制文件——无法内嵌预览。",
"file_viewer_copy_install_command": "复制安装命令",
"file_viewer_copy_contents": "复制文件内容",
"file_viewer_copy_failed": "无法复制文件内容",
"file_viewer_discard_my_edits_and_reload": "放弃我的编辑并重新加载",
"file_viewer_dismiss": "关闭",
"file_viewer_dismiss_compile_message": "关闭编译消息",
+61 -10
View File
@@ -64,7 +64,7 @@ import { HtmlPreview } from "./HtmlPreview";
import { OverleafButton } from "./OverleafPanel";
import { MediaPreview, mediaPreviewKind } from "./MediaPreview";
import { Md } from "./Md";
import { Button, IconButton, IconButtonLink, Spinner } from "./ui";
import { Button, IconButton, IconButtonLink, Spinner, showAlert } from "./ui";
export interface FileScrollPosition {
top: number;
@@ -241,6 +241,8 @@ export function FileViewer({
const saveError = bufferSession.saveError;
const setSaveError = bufferSession.setSaveError;
const bodyRef = useRef<HTMLDivElement>(null);
const selectableContentRef = useRef<HTMLDivElement>(null);
const [copiedContents, setCopiedContents] = useState(false);
const scrollPositionRef = useRef(scrollPosition);
const data = loaded?.file ?? null;
// A cited `artifacts/…` file can answer from either name in the checkout, so
@@ -395,6 +397,41 @@ export function FileViewer({
const showingEditor = (editable || showingUnsafeDraft) &&
!(rendersByDefault && !showSource) &&
!showingPdf;
const canCopyContents = data !== null && !data.notFound && !data.binary && !mediaKind && !showingPdf;
const canSelectContents = canCopyContents && !showingEditor && (!isHtml || showSource);
const copyContents = async () => {
if (!data) return;
try {
if (data.truncated && !showingEditor) {
// WebKit requires the clipboard write to start during the click.
const content = fetch(rawFileUrl(filePath)).then(async (response) => {
if (!response.ok) throw new Error();
return new Blob([await response.text()], { type: "text/plain" });
});
await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]);
} else {
await navigator.clipboard.writeText(showingEditor ? draft : data.content);
}
setCopiedContents(true);
window.setTimeout(() => setCopiedContents(false), 1500);
} catch {
showAlert(m.file_viewer_copy_failed(), "error");
}
};
const selectViewerContents = (event: React.KeyboardEvent<HTMLDivElement>) => {
if ((!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "a") return;
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target)) return;
const content = selectableContentRef.current;
if (!content) return;
event.preventDefault();
const range = document.createRange();
range.selectNodeContents(content);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
};
// `#toolbar=0` asks the browser's PDF viewer to drop its own chrome, so the
// pane shows the document and this view's header owns the controls.
@@ -526,7 +563,7 @@ export function FileViewer({
};
return (
<div className="file-view flex flex-col h-full min-h-0 min-w-0">
<div className="file-view flex flex-col h-full min-h-0 min-w-0" onKeyDown={selectViewerContents}>
<div className="file-view-header flex w-full min-w-0 min-h-9 items-center gap-1 px-4 py-1 bg-background text-text shrink-0">
<FileTypeIcon name={filePath} />
<span className="file-view-path flex-1 min-w-0 truncate text-sm text-subtext" data-tip={ltr(filePath)}>
@@ -617,6 +654,17 @@ export function FileViewer({
<Code size={13} />
</IconButton>
)}
{canCopyContents && (
<IconButton
size="small"
data-tip={copiedContents ? m.common_copied() : m.file_viewer_copy_contents()}
data-tip-align="end"
aria-label={copiedContents ? m.common_copied() : m.file_viewer_copy_contents()}
onClick={() => void copyContents()}
>
{copiedContents ? <Check size={13} /> : <Copy size={13} />}
</IconButton>
)}
{data != null && (
<>
<IconButton
@@ -747,6 +795,7 @@ export function FileViewer({
<div
ref={bodyRef}
className="file-view-body flex-1 min-h-0 overflow-auto bg-background"
tabIndex={canSelectContents ? 0 : undefined}
onScroll={(event) => {
const position = {
top: Math.max(0, event.currentTarget.scrollTop),
@@ -820,7 +869,7 @@ export function FileViewer({
downloadBar={false}
/>
) : isMarkdown && !showSource ? (
<div className="file-view-md max-w-readable pt-4.5 px-5 pb-8 [&_.md]:text-base [&_.md_h1]:text-2xl [&_.md_h1]:mt-4.5 [&_.md_h1]:mx-0 [&_.md_h1]:mb-2 [&_.md_h2]:text-xl [&_.md_h2]:mt-4 [&_.md_h2]:mx-0 [&_.md_h2]:mb-2 [&_.md_h3]:text-lg">
<div ref={selectableContentRef} className="file-view-md max-w-readable pt-4.5 px-5 pb-8 [&_.md]:text-base [&_.md_h1]:text-2xl [&_.md_h1]:mt-4.5 [&_.md_h1]:mx-0 [&_.md_h1]:mb-2 [&_.md_h2]:text-xl [&_.md_h2]:mt-4 [&_.md_h2]:mx-0 [&_.md_h2]:mb-2 [&_.md_h3]:text-lg">
{artifactsMode ? (
<ArtifactMarkdown
projectId={projectId}
@@ -851,13 +900,15 @@ export function FileViewer({
/>
) : (
<>
<CodeView
text={data.content}
path={path}
highlightLine={line}
scrollRequest={lineScrollRequest}
onScrollRequestHandled={onLineScrollRequestHandled}
/>
<div ref={selectableContentRef}>
<CodeView
text={data.content}
path={path}
highlightLine={line}
scrollRequest={lineScrollRequest}
onScrollRequestHandled={onLineScrollRequestHandled}
/>
</div>
{data.truncated && (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">{m.file_viewer_file_truncated_showing_the_first_512_kb()}</div>
)}