feat(chat): open workspace links in preview
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Chat markdown links using `workspace://path/to/file` now open the target in the workspace preview pane instead of navigating away from the WebUI.
|
||||
|
||||
## [v0.51.137] — 2026-05-25 — Release DI (stage-batch19 — 6-PR medium-risk batch)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -441,6 +441,7 @@ Production data and real cron jobs are never touched. Current snapshot:
|
||||
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
|
||||
- Breadcrumb navigation with clickable path segments
|
||||
- Preview text, code, Markdown (rendered), and images inline
|
||||
- Chat links using `workspace://path/to/file` open files in the right-side preview pane
|
||||
- Edit, create, delete, and rename files; create folders
|
||||
- Binary file download (auto-detected from server)
|
||||
- File preview auto-closes on directory navigation (with unsaved-edit guard)
|
||||
|
||||
@@ -1056,8 +1056,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Raw file:// anchors are rewritten to /api/media before the user can click them.
|
||||
const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.|api)/i;
|
||||
const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i;
|
||||
function _smdFileHref(raw){
|
||||
function _smdLinkHref(raw){
|
||||
const href=String(raw||'');
|
||||
if(/^workspace:\/\//i.test(href)){
|
||||
try{
|
||||
const rel=decodeURIComponent(href.replace(/^workspace:\/\//i,'')).replace(/^~\//,'').replace(/^\.\//,'');
|
||||
return '#workspace='+encodeURIComponent(rel);
|
||||
}catch(_){
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
if(!/^file:\/\//i.test(href)) return href;
|
||||
try{
|
||||
const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
|
||||
@@ -1066,12 +1074,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1';
|
||||
}
|
||||
}
|
||||
function _smdFileHref(raw){
|
||||
return _smdLinkHref(raw);
|
||||
}
|
||||
function _sanitizeSmdLinks(root){
|
||||
if(!root||!root.querySelectorAll) return;
|
||||
const _a=root.querySelectorAll('a[href]');
|
||||
for(let i=0;i<_a.length;i++){
|
||||
const n=_a[i],v=n.getAttribute('href')||'';
|
||||
if(/^file:\/\//i.test(v)){n.setAttribute('href',_smdFileHref(v));continue;}
|
||||
if(/^(file|workspace):\/\//i.test(v)){n.setAttribute('href',_smdLinkHref(v));continue;}
|
||||
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('href');n.setAttribute('data-blocked-scheme','1');}
|
||||
}
|
||||
const _im=root.querySelectorAll('img[src]');
|
||||
@@ -1182,8 +1193,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const isHref=window.smd&&attr===window.smd.HREF;
|
||||
const isSrc=window.smd&&attr===window.smd.SRC;
|
||||
const safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE;
|
||||
if(isHref&&/^file:\/\//i.test(String(value||''))){
|
||||
baseSetAttr(data,attr,_smdFileHref(value));
|
||||
if(isHref&&/^(file|workspace):\/\//i.test(String(value||''))){
|
||||
baseSetAttr(data,attr,_smdLinkHref(value));
|
||||
return;
|
||||
}
|
||||
if((isHref||isSrc)&&!safeUrl.test(String(value||''))){
|
||||
|
||||
22
static/ui.js
22
static/ui.js
@@ -528,6 +528,16 @@ function _closeImgLightbox(lb) {
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if(!e.target || !e.target.closest) return;
|
||||
const workspaceLink=e.target.closest('a[href^="#workspace="]');
|
||||
if(workspaceLink){
|
||||
e.preventDefault();
|
||||
const href=workspaceLink.getAttribute('href')||'';
|
||||
try{
|
||||
const rel=decodeURIComponent(href.slice('#workspace='.length));
|
||||
if(rel && typeof openArtifactPath==='function') openArtifactPath(rel);
|
||||
}catch(_){}
|
||||
return;
|
||||
}
|
||||
// Message-attached images (already wired since v0.50.x).
|
||||
let img = e.target.closest('.msg-media-img');
|
||||
if(img){ _openImgLightbox(img.src, img.alt); return; }
|
||||
@@ -2960,7 +2970,7 @@ function renderMd(raw){
|
||||
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
|
||||
// Stash [label](url) links before autolink so the URL in href= is not re-linked
|
||||
const _link_stash=[];
|
||||
t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
|
||||
t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
|
||||
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;});
|
||||
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
|
||||
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
|
||||
@@ -3053,7 +3063,7 @@ function renderMd(raw){
|
||||
// Stash existing <a> tags first to avoid re-linking already-linked URLs.
|
||||
const _a_stash=[];
|
||||
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
|
||||
s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||||
s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|workspace:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
|
||||
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
|
||||
// Restore raw <pre> only after markdown rewrites so literal preformatted
|
||||
// content stays placeholder-protected, then let the sanitizer normalize tags.
|
||||
@@ -3071,6 +3081,14 @@ function renderMd(raw){
|
||||
}
|
||||
function _markdownHref(raw){
|
||||
const href=String(raw||'').replace(/"/g,'%22');
|
||||
if(/^workspace:\/\//i.test(href)){
|
||||
try{
|
||||
const rel=decodeURIComponent(href.replace(/^workspace:\/\//i,'')).replace(/^~\//,'').replace(/^\.\//,'');
|
||||
return '#workspace='+encodeURIComponent(rel);
|
||||
}catch(_){
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
if(/^file:\/\//i.test(href)){
|
||||
try{
|
||||
const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
|
||||
|
||||
@@ -255,10 +255,29 @@ function renderSessionArtifacts(){
|
||||
root.innerHTML = items.map(item => `<button type="button" class="workspace-artifact-item" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)"><div class="workspace-artifact-path">${esc(item.path)}</div><div class="workspace-artifact-meta">${esc(item.source || 'session')}</div></button>`).join('');
|
||||
}
|
||||
|
||||
function openArtifactPath(path){
|
||||
async function _workspacePathExists(path){
|
||||
if(!S.session||!path) return false;
|
||||
const parts=String(path).split('/').filter(Boolean);
|
||||
const name=parts.pop();
|
||||
if(!name) return false;
|
||||
const dir=parts.length?parts.join('/'):'.';
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dir)}`);
|
||||
return (data.entries||[]).some(entry=>entry&&((entry.path===path)||entry.name===name));
|
||||
}
|
||||
|
||||
async function openArtifactPath(path){
|
||||
if(!path) return;
|
||||
switchWorkspacePanelTab('files');
|
||||
const rel = path.replace(/^~\//,'').replace(/^\.\//,'');
|
||||
try{
|
||||
if(!(await _workspacePathExists(rel))){
|
||||
setStatus(t('file_open_failed'));
|
||||
return;
|
||||
}
|
||||
}catch(_){
|
||||
setStatus(t('file_open_failed'));
|
||||
return;
|
||||
}
|
||||
openFile(rel);
|
||||
}
|
||||
|
||||
|
||||
102
tests/test_issue2881_workspace_chat_links.py
Normal file
102
tests/test_issue2881_workspace_chat_links.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Regression coverage for workspace:// chat links opening workspace preview (#2881)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
_DRIVER_SRC = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[2], 'utf8');
|
||||
global.window = {};
|
||||
global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) };
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
|
||||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
|
||||
const _SVG_EXTS=/\.svg$/i;
|
||||
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
|
||||
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
return src.slice(start, i);
|
||||
}
|
||||
eval(extractFunc('_matchBacktickFenceLine'));
|
||||
eval(extractFunc('_isBacktickFenceClose'));
|
||||
eval(extractFunc('renderMd'));
|
||||
|
||||
let buf = '';
|
||||
process.stdin.on('data', c => { buf += c; });
|
||||
process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); });
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def driver_path(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("issue2881_renderer") / "driver.js"
|
||||
path.write_text(_DRIVER_SRC, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _render(driver_path: str, markdown: str) -> str:
|
||||
result = subprocess.run(
|
||||
[NODE, driver_path, str(REPO_ROOT / "static" / "ui.js")],
|
||||
input=markdown,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def test_render_md_rewrites_workspace_links_to_internal_anchor(driver_path):
|
||||
html = _render(driver_path, "[Open plan](workspace://notes/plan.md)")
|
||||
|
||||
assert 'href="#workspace=notes%2Fplan.md"' in html
|
||||
assert "workspace://notes/plan.md" not in html
|
||||
assert ">Open plan</a>" in html
|
||||
|
||||
|
||||
def test_render_md_does_not_autolink_raw_workspace_urls(driver_path):
|
||||
html = _render(driver_path, "Open workspace://notes/plan.md manually")
|
||||
|
||||
assert '<a href="#workspace=' not in html
|
||||
assert "workspace://notes/plan.md" in html
|
||||
|
||||
|
||||
def test_workspace_link_click_delegate_opens_workspace_preview():
|
||||
assert 'a[href^="#workspace="]' in UI_JS
|
||||
assert "decodeURIComponent" in UI_JS
|
||||
assert "openArtifactPath(rel)" in UI_JS
|
||||
assert "async function openArtifactPath(path)" in (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
assert "/api/list?session_id=" in (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
assert "file_open_failed" in (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_streaming_markdown_rewrites_workspace_links_before_sanitizing():
|
||||
assert "function _smdLinkHref" in MESSAGES_JS
|
||||
assert "workspace:\\/\\/" in MESSAGES_JS
|
||||
assert "'#workspace='" in MESSAGES_JS
|
||||
assert "_smdLinkHref(v)" in MESSAGES_JS
|
||||
assert "_smdLinkHref(value)" in MESSAGES_JS
|
||||
Reference in New Issue
Block a user