fix(files): give the file preview a working detach button

The button next to the file preview's close icon was Copy Content, whose
overlapping-pages glyph reads as a pop-out control - and for a PDF or any
media/binary preview it was completely dead: those branches never fill
filePreviewContent, so the click hit an empty-content guard and did nothing,
with no feedback.

There is now a real detach button that opens the previewed file in a browser
tab (raw route for PDFs/images/media/text, the server-converted PDF preview
for docx/pptx), severs window.opener by hand so a blocked pop-up stays
detectable, closes the overlay on success (which also stops any playing
media), and disarms on close so it can never open a stale file. The copy
button now toasts 'Nothing to copy in this preview' instead of staying
silent.

Verified live with Playwright against an isolated instance: button visible
and armed on a PDF preview, file-raw answers 200, clicking opens the URL and
tears the overlay down, text previews keep a working copy buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-24 00:59:27 +02:00
parent 82f81d21c4
commit f3c615b669
3 changed files with 203 additions and 0 deletions
+1
View File
@@ -567,6 +567,7 @@
<div class="file-preview-actions">
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">&times;</button>
</div>
</div>
+45
View File
@@ -3313,6 +3313,11 @@ Object.assign(CodemanApp.prototype, {
// Stop whatever the previous preview was playing. Overwriting innerHTML
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
this._stopFilePreviewMedia();
// Disarm detach until this load has a URL of its own: an early error return
// must not leave the button opening the PREVIOUS file in a new tab.
this.filePreviewDetachUrl = '';
const detachBtn = this.$('filePreviewDetachBtn');
if (detachBtn) detachBtn.hidden = true;
// Show overlay with loading state
overlay.classList.add('visible');
@@ -3340,6 +3345,19 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Every branch below renders from one of these routes, so the detach button
// can always offer the same bytes in a browser tab: docx/pptx through the
// server-converted PDF preview, everything else through the raw route.
// (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx';
this.filePreviewDetachUrl = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
if (detachBtn) detachBtn.hidden = false;
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
@@ -3489,6 +3507,29 @@ Object.assign(CodemanApp.prototype, {
// audible and keeps streaming from the server. Closing has to stop it.
this._stopFilePreviewMedia();
this.filePreviewContent = '';
this.filePreviewDetachUrl = '';
const detachBtn = this.$('filePreviewDetachBtn');
if (detachBtn) detachBtn.hidden = true;
},
/**
* Open the previewed file in a browser tab and close the overlay.
*
* window.open is called WITHOUT the 'noopener' feature string: with it the
* call returns null even on success, which would make a blocked pop-up
* indistinguishable from a working one. The opener link is severed by hand
* instead, and a null return then reliably means the browser blocked it, in
* which case the overlay stays up so the user has not lost the file.
*/
detachFilePreview() {
if (!this.filePreviewDetachUrl) return;
const win = window.open(this.filePreviewDetachUrl, '_blank');
if (!win) {
this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error');
return;
}
win.opener = null;
this.closeFilePreview();
},
/**
@@ -4127,6 +4168,10 @@ Object.assign(CodemanApp.prototype, {
}).catch(() => {
this.showToast('Failed to copy', 'error');
});
} else {
// Media/PDF/binary previews have no text buffer. Saying so beats the
// dead-button silence this used to be.
this.showToast('Nothing to copy in this preview', 'info');
}
},