Skip to content

Commit 8e8e37d

Browse files
kjkclaude
andcommitted
Keep a pathless tab when it is dragged out
Dragging a tab out of its window closes it and reloads the document by path in the new window. A tab opened from an embedded attachment has no path, so the drag destroyed it and CreateEngineFromFile reported an empty path. Leave such a tab in its window. crash: 2026-10-13-12-51-c0c4 (e0444247 len(path) == 0 at CreateEngineFromFile, src/shared/EngineCreate.cpp:345) prompt: /fix-crashes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 485e825 commit 8e8e37d

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

‎src/Tabs.cpp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ static void MaybeMigrateTab(WindowTab* tab, MainWindow* newWin, Point releasePt)
199199
}
200200
if (nDocTabs == 1 && !newWin) return;
201201

202+
// migration reloads by path; a document opened from memory (an embedded
203+
// attachment) has none
204+
if (len(tab->filePath) == 0) {
205+
return;
206+
}
207+
202208
auto* engine = tab->GetEngine();
203209
if (EngineHasUnsavedAnnotations(engine)) {
204210
return;

‎tests/drag-out-attachment-tab.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Dragging a tab out of its window reloads the document by path in the new
2+
// window. A tab opened from an embedded attachment has no path: the drag closed
3+
// it and fired the empty-path debug report in CreateEngineFromFile()
4+
// (crash 2026-10-13-12-51-c0c4). Such a tab now stays where it is.
5+
6+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7+
import { join } from "node:path";
8+
import { ControlCommand } from "./control.ts";
9+
import { assemblePdf, pollUntil, runStandalone, SLOW_BUILD_FACTOR, tmpPath } from "./util.ts";
10+
import {
11+
clientToScreen,
12+
getClientRect,
13+
getWindowText,
14+
MK_LBUTTON,
15+
packCoords,
16+
postMessage,
17+
setCursorPos,
18+
sleep,
19+
WM_LBUTTONDOWN,
20+
WM_LBUTTONUP,
21+
WM_MOUSEMOVE,
22+
} from "./winapi.ts";
23+
import { clickAt, findChildByClass, killAndWait, launchControlled } from "./win-automation.ts";
24+
25+
const TABS_CLASS = "SumatraTabsCtrlClass";
26+
const ATTACHMENT = "image002.png";
27+
28+
// 1x1 red PNG
29+
const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
30+
31+
function makePdfWithImageAttachment(): string {
32+
const png = Buffer.from(PNG_BASE64, "base64").toString("latin1");
33+
return assemblePdf([
34+
"<< /Type /Catalog /Pages 2 0 R >>",
35+
"<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
36+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R] >>",
37+
"<< /Type /Annot /Subtype /FileAttachment /P 3 0 R /Rect [200 400 400 600] /FS 5 0 R /Name /PushPin >>",
38+
`<< /Type /Filespec /F (${ATTACHMENT}) /UF (${ATTACHMENT}) /EF << /F 6 0 R >> >>`,
39+
`<< /Type /EmbeddedFile /Length ${png.length} /Params << /Size ${png.length} >> >>\nstream\n${png}\nendstream`,
40+
]);
41+
}
42+
43+
// x of a point on the attachment's tab: the tab a click there selects.
44+
// Scans from the left so the first hit is far from the tab's close button.
45+
async function findAttachmentTabX(frame: number, tabs: number, y: number): Promise<number> {
46+
const width = getClientRect(tabs).right;
47+
const step = 30;
48+
// start on the first tab (Home)
49+
await clickAt(tabs, step, y, 50);
50+
for (let x = 2 * step; x < width; x += step) {
51+
await clickAt(tabs, x, y, 50);
52+
if (getWindowText(frame).includes(ATTACHMENT)) {
53+
return x;
54+
}
55+
}
56+
throw new Error("drag-out-attachment-tab: could not find the attachment's tab");
57+
}
58+
59+
export async function testit(): Promise<void> {
60+
const dir = tmpPath("drag-out-attachment-tab");
61+
rmSync(dir, { recursive: true, force: true });
62+
mkdirSync(dir, { recursive: true });
63+
const pdf = join(dir, "outer.pdf");
64+
writeFileSync(pdf, makePdfWithImageAttachment(), "latin1");
65+
66+
const { proc, client, frame } = await launchControlled(["-view", "single page", "-zoom", "fit page", pdf]);
67+
try {
68+
await client.waitForRenderIdle();
69+
await client.setNotificationsEnabled(false);
70+
71+
await pollUntil(
72+
async () => {
73+
const res = await client.request(ControlCommand.TestMarkupAnnots, ["open-embedded", 0, 0]);
74+
return { code: res[0], text: String(res[1] ?? "") };
75+
},
76+
(r) => r.code === 0 && /OK/.test(r.text),
77+
{
78+
timeoutMs: 8000 * SLOW_BUILD_FACTOR,
79+
error: (r) => `drag-out-attachment-tab: open-embedded failed: code=${r.code} ${r.text}`,
80+
},
81+
);
82+
await pollUntil(
83+
() => getWindowText(frame),
84+
(t) => t.includes(ATTACHMENT),
85+
{
86+
timeoutMs: 8000 * SLOW_BUILD_FACTOR,
87+
error: (t) => `drag-out-attachment-tab: expected tab ${ATTACHMENT}, title='${t}'`,
88+
},
89+
);
90+
await client.waitForRenderIdle();
91+
92+
const tabs = findChildByClass(frame, TABS_CLASS);
93+
if (!tabs) {
94+
throw new Error("drag-out-attachment-tab: no tab bar");
95+
}
96+
const y = Math.floor(getClientRect(tabs).bottom / 2);
97+
const x = await findAttachmentTabX(frame, tabs, y);
98+
99+
// press on the tab, pull it down past the drag threshold, drop below the bar
100+
const dropY = y + 300;
101+
const at = clientToScreen(tabs, x, y);
102+
setCursorPos(at.x, at.y);
103+
postMessage(tabs, WM_LBUTTONDOWN, MK_LBUTTON, packCoords(x, y));
104+
postMessage(tabs, WM_MOUSEMOVE, MK_LBUTTON, packCoords(x, y + 40));
105+
postMessage(tabs, WM_MOUSEMOVE, MK_LBUTTON, packCoords(x, dropY));
106+
postMessage(tabs, WM_LBUTTONUP, 0, packCoords(x, dropY));
107+
await sleep(300 * SLOW_BUILD_FACTOR);
108+
109+
// a debug report under -for-testing ends the process
110+
if (proc.exitCode !== null) {
111+
throw new Error(`drag-out-attachment-tab: the app exited with ${proc.exitCode} after the drag`);
112+
}
113+
await client.waitForRenderIdle();
114+
const title = getWindowText(frame);
115+
if (!title.includes(ATTACHMENT)) {
116+
throw new Error(`drag-out-attachment-tab: the attachment tab is gone, title='${title}'`);
117+
}
118+
} finally {
119+
client.close();
120+
await killAndWait(proc);
121+
}
122+
console.log("drag-out-attachment-tab: OK");
123+
}
124+
125+
if (import.meta.main) {
126+
await runStandalone(testit);
127+
}

‎tests/run-almost-all.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ import { testit as issue6305 } from "./issue-6305.ts";
180180
import { testit as epubThemeRestyle } from "./epub-theme-restyle.ts";
181181
import { testit as showChaptersInEbooks } from "./show-chapters-in-ebooks.ts";
182182
import { testit as embeddedImageAttachment } from "./embedded-image-attachment.ts";
183+
import { testit as dragOutAttachmentTab } from "./drag-out-attachment-tab.ts";
183184
import { testit as ttsEngineCrashRecovery } from "./tts-engine-crash-recovery.ts";
184185
import { testit as readAloudCloseDuringSpeak } from "./read-aloud-close-during-speak.ts";
185186
import { testit as readAloudRestyleStalePage } from "./read-aloud-restyle-stale-page.ts";
@@ -504,6 +505,7 @@ export const tests: NamedTest[] = [
504505
["issue-6135", issue6135],
505506
["issue-4276", issue4276],
506507
["embedded-image-attachment", embeddedImageAttachment],
508+
["drag-out-attachment-tab", dragOutAttachmentTab],
507509
["tts-engine-crash-recovery", ttsEngineCrashRecovery],
508510
["read-aloud-close-during-speak", readAloudCloseDuringSpeak],
509511
["read-aloud-restyle-stale-page", readAloudRestyleStalePage],

0 commit comments

Comments
 (0)