From c4dda58ba29a105d9262bfb48b259342beded1bb Mon Sep 17 00:00:00 2001 From: markbus-ai <58405544+markbus-ai@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:01:37 -0300 Subject: [PATCH] Stop the clipboard picker freezing on huge pastes (#6568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every keystroke in the search box scanned, lowercased, and split the full text of every history entry, and the preview pane laid out the entire selection with WrapAnywhere. A single 1.6MB paste (or a large file selection) turned that into hundreds of megabytes of work on the shell thread and stalled the render thread — freezing the whole desktop. Cap each entry once as it enters the display, so searching, previewing, and rendering all work on a bounded prefix. Pasting reads the full entry back from history by index, so nothing is actually lost. The cut lands on a line break, keeping a file:// URI from truncating into a bogus path. Co-authored-by: markbusking Co-authored-by: Claude Opus 5 (1M context) --- shell/plugins/clipboard/ClipboardHistory.js | 16 ++++++++++++- test/shell.d/clipboard-test.sh | 26 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/shell/plugins/clipboard/ClipboardHistory.js b/shell/plugins/clipboard/ClipboardHistory.js index 54433f0c..49008ccc 100644 --- a/shell/plugins/clipboard/ClipboardHistory.js +++ b/shell/plugins/clipboard/ClipboardHistory.js @@ -157,6 +157,20 @@ function fullText(entry) { return String(entry.text || "") } +// The picker only ever searches and renders a prefix of an entry, so scan and +// render just that much. A single huge paste otherwise costs hundreds of +// megabytes of string work on every keystroke and stalls the whole shell. +// Pasting reads the full entry back from history by index, so nothing is lost. +var displayTextLimit = 8192 + +function cappedEntry(entry) { + if (!entry || entry.type !== "text" || entry.text.length <= displayTextLimit) return entry + + // Cut on a line break so a file:// URI never truncates into a bogus path. + var cut = entry.text.lastIndexOf("\n", displayTextLimit) + return { type: "text", text: entry.text.slice(0, cut > 0 ? cut : displayTextLimit) } +} + function displayRows(history, query, limit) { var values = Array.isArray(history) ? history : [] var needle = String(query || "").trim().toLowerCase() @@ -168,7 +182,7 @@ function displayRows(history, query, limit) { var rows = [] for (var i = 0; i < values.length; i++) { - var entry = normalizeEntry(values[i]) + var entry = cappedEntry(normalizeEntry(values[i])) if (!entry) continue if (needle && searchableText(entry).toLowerCase().indexOf(needle) < 0) continue diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index 79ce6fb8..f83c6322 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -179,6 +179,32 @@ assertEqual( 2, 'clipboard respawns both watchers when they die' ) + +assertDeepEqual( + clipboard.displayRows([{ type: 'text', text: 'a'.repeat(8192) + 'needle' }], 'needle', 50), + [], + 'clipboard display rows do not search text past the cap' +) + +assertDeepEqual( + clipboard.displayRows([{ type: 'text', text: 'needle' + 'a'.repeat(100000) }], 'needle', 50).map(row => row.index), + [0], + 'clipboard display rows search text up to the cap' +) + +const hugeTextRow = clipboard.displayRows([{ type: 'text', text: 'z'.repeat(100000) }], '', 50)[0] +assert( + hugeTextRow.fullText.length === 8192 && hugeTextRow.previewText.length === 8192, + 'clipboard display rows cap what a huge text entry renders' +) + +const hugeFileList = [] +for (let i = 0; i < 5000; i++) hugeFileList.push('file:///home/dhh/clip-' + i + '.mp4') +const hugeFileRow = clipboard.displayRows([{ type: 'text', text: hugeFileList.join('\n') + '\n' }], '', 50)[0] +assert( + hugeFileRow.entryType === 'file' && hugeFileRow.fullText.split('\n').every(path => path.endsWith('.mp4')), + 'clipboard display rows cap a huge file list without truncating a path' +) JS TMPDIR=$(mktemp -d)