diff --git a/static/index.html b/static/index.html
index 4d40fed8..7e24f0fc 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1645,6 +1645,7 @@
+
diff --git a/static/style.css b/static/style.css
index 70509695..d7943d8c 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3377,7 +3377,7 @@ body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;}
.preview-path{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;overflow:hidden;min-width:0;}
.preview-path #previewPathText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.preview-path #previewBadge{flex-shrink:0;white-space:nowrap;}
-.preview-path #btnDownloadFile,.preview-path #btnEditFile,.preview-path #btnRenderMarkdownAnyway{flex-shrink:0;white-space:nowrap;}
+.preview-path #btnCopyPreviewPath,.preview-path #btnDownloadFile,.preview-path #btnEditFile,.preview-path #btnRenderMarkdownAnyway{flex-shrink:0;white-space:nowrap;}
/* Preview badge typography */
.preview-badge{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;}
diff --git a/static/workspace.js b/static/workspace.js
index 296d6be7..f3c60a8e 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -1216,6 +1216,35 @@ function openInBrowser(){
}
// openInBrowser keeps the helper-based raw path, which expands to an explicit &inline=1 URL.
+async function copyPreviewFilePath(){
+ if(!_previewCurrentPath||!S.session) return;
+ try{
+ const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:_previewCurrentPath})});
+ const abs=(r&&r.path)||_previewCurrentPath;
+ if(typeof _copyTextWithFallback==='function'){
+ await _copyTextWithFallback(abs,t('path_copied'),t('path_copy_failed'));
+ return;
+ }
+ try{
+ await navigator.clipboard.writeText(abs);
+ showToast(t('path_copied'));
+ }catch(clipErr){
+ const ta=document.createElement('textarea');
+ ta.value=abs;
+ ta.style.cssText='position:fixed;left:-9999px;top:-9999px;';
+ document.body.appendChild(ta);
+ ta.select();
+ let copied=false;
+ try{copied=document.execCommand('copy');}catch(_){}
+ ta.remove();
+ if(copied) showToast(t('path_copied'));
+ else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr)));
+ }
+ }catch(err){
+ showToast(t('path_copy_failed')+(err.message||err));
+ }
+}
+
// ── Workspace upload ──────────────────────────────────────────────────
function triggerWorkspaceUpload() {
if(_workspacePathIsReadOnly(S.currentDir || '.')){
diff --git a/tests/test_issue5533_preview_copy_file_path.py b/tests/test_issue5533_preview_copy_file_path.py
new file mode 100644
index 00000000..9c1999c1
--- /dev/null
+++ b/tests/test_issue5533_preview_copy_file_path.py
@@ -0,0 +1,69 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
+WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
+STYLE = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
+
+
+def _function_body(src: str, name: str) -> str:
+ start = src.index(f"function {name}(")
+ brace = src.index("{", start)
+ depth = 0
+ in_string = ""
+ escape = False
+ for idx in range(brace, len(src)):
+ ch = src[idx]
+ if in_string:
+ if escape:
+ escape = False
+ elif ch == "\\":
+ escape = True
+ elif ch == in_string:
+ in_string = ""
+ continue
+ if ch in "'\"`":
+ in_string = ch
+ continue
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return src[start : idx + 1]
+ raise AssertionError(f"{name} body did not close")
+
+
+def test_preview_toolbar_has_copy_file_path_button():
+ assert 'id="btnCopyPreviewPath"' in INDEX
+ assert 'onclick="copyPreviewFilePath()"' in INDEX
+ assert 'data-i18n="copy_file_path"' in INDEX
+ assert "Copy file path" in INDEX
+
+
+def test_preview_copy_file_path_uses_server_resolved_current_preview_path():
+ body = _function_body(WORKSPACE_JS, "copyPreviewFilePath")
+ assert "if(!_previewCurrentPath||!S.session) return;" in body
+ assert "api('/api/file/path'" in body
+ assert "session_id:S.session.session_id" in body
+ assert "path:_previewCurrentPath" in body
+ assert "const abs=(r&&r.path)||_previewCurrentPath" in body
+
+
+def test_preview_copy_file_path_reuses_clipboard_fallback_and_toasts():
+ body = _function_body(WORKSPACE_JS, "copyPreviewFilePath")
+ assert "typeof _copyTextWithFallback==='function'" in body
+ assert "_copyTextWithFallback(abs,t('path_copied'),t('path_copy_failed'))" in body
+ assert "navigator.clipboard.writeText(abs)" in body
+ assert "document.execCommand('copy')" in body
+ assert "t('path_copied')" in body
+ assert "t('path_copy_failed')" in body
+
+
+def test_preview_toolbar_keeps_copy_button_from_shrinking_path_layout():
+ assert ".preview-path #btnCopyPreviewPath" in STYLE
+ selector_start = STYLE.index(".preview-path #btnCopyPreviewPath")
+ selector_block = STYLE[selector_start : STYLE.index("}", selector_start) + 1]
+ assert "flex-shrink:0" in selector_block
+ assert "white-space:nowrap" in selector_block