diff --git a/static/ui.js b/static/ui.js index 412d36bf..ee64f08d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14772,16 +14772,39 @@ function _setWsDragData(e,item){ _wsActiveDragPath=item.path; _wsActiveDragType=item.type; } +function _clearWsDragData(){ + _wsActiveDragPath=null; + _wsActiveDragType=null; +} +// Window-level fallback cleanup: if a workspace drag is abandoned without the +// row's ondragend firing (drag cancelled, dropped outside any target, tab +// blurred/hidden mid-drag), the active-drag flag must not survive — otherwise a +// later FOREIGN text/plain drag could be misread as a workspace move. +if(typeof window!=='undefined'&&!window._wsDragCleanupBound){ + window._wsDragCleanupBound=true; + window.addEventListener('dragend',_clearWsDragData,true); + window.addEventListener('drop',_clearWsDragData,true); + window.addEventListener('pagehide',_clearWsDragData); + window.addEventListener('blur',_clearWsDragData); +} function _isWorkspaceTreeMoveDrag(e){ if(e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('Files')) return false; if(e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('application/ws-path')) return true; + // Stripped-MIME (macOS WebKit) fallback: accept text/plain ONLY while a + // workspace drag is genuinely in flight. dragover/drop events can't read the + // payload, so gate on the active flag alone here; the drop handler additionally + // proves text/plain === _wsActiveDragPath before performing the move. return !!(_wsActiveDragPath&&e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('text/plain')); } function _wsDragSrcPath(e){ const custom=e.dataTransfer.getData('application/ws-path'); if(custom) return custom; - if(_wsActiveDragPath) return _wsActiveDragPath; - return e.dataTransfer.getData('text/plain')||''; + // Stripped-MIME fallback: only trust the active flag when the drop's own + // text/plain matches it. A foreign text/plain drag (different/empty content) + // must NOT resolve to our tracked workspace path even if the flag lingered. + const plain=e.dataTransfer.getData('text/plain')||''; + if(_wsActiveDragPath&&plain===_wsActiveDragPath) return _wsActiveDragPath; + return ''; } function _wsDragSrcType(e){ const custom=e.dataTransfer.getData('application/ws-type'); @@ -14872,10 +14895,14 @@ function _bindWorkspaceMoveDropTarget(el,destDir){ if(!_isWorkspaceTreeMoveDrag(e))return; e.preventDefault();e.stopPropagation(); el.classList.remove('drag-over'); - const srcPath=_wsDragSrcPath(e); - if(!srcPath)return; - const srcType=_wsDragSrcType(e); - await _performWorkspaceMove(srcPath,destDir,srcType==='dir'); + try{ + const srcPath=_wsDragSrcPath(e); + if(!srcPath)return; + const srcType=_wsDragSrcType(e); + await _performWorkspaceMove(srcPath,destDir,srcType==='dir'); + }finally{ + _clearWsDragData(); + } }; } @@ -14898,7 +14925,7 @@ function _renderTreeItems(container, entries, depth){ e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item); }; el.ondragstart=(e)=>{_setWsDragData(e,item);e.dataTransfer.effectAllowed='copy';el.classList.add('dragging');}; - el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();_wsActiveDragPath=null;_wsActiveDragType=null;}; + el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();_clearWsDragData();}; const isLk = item.type === 'symlink'; const isExternalLink = isLk && item.target_outside_workspace; diff --git a/tests/test_issue4757_macos_drag_drop_mime.py b/tests/test_issue4757_macos_drag_drop_mime.py index 7b7fc4d7..05b43d01 100644 --- a/tests/test_issue4757_macos_drag_drop_mime.py +++ b/tests/test_issue4757_macos_drag_drop_mime.py @@ -51,6 +51,7 @@ def _extract_drag_functions(): extract_function(name) for name in ( '_setWsDragData', + '_clearWsDragData', '_isWorkspaceTreeMoveDrag', '_wsDragSrcPath', '_wsDragSrcType', @@ -131,6 +132,42 @@ process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt}))); assert _run_node(js) == 'false', \ 'foreign text drag must be rejected when no active-drag flag is set' + def test_stale_flag_foreign_text_resolves_empty(self): + """If the active-drag flag lingers (lost dragend) and a FOREIGN text/plain + drag arrives whose content differs from the tracked path, _wsDragSrcPath + must return '' (not the stale path) so no spurious workspace move runs. + + Regression guard for the gate-found SILENT hazard: the stripped-MIME + fallback now requires text/plain === _wsActiveDragPath.""" + js = """\ +const item = {path: 'docs/readme.md', type: 'file'}; +const startDt = new FakeDataTransfer([], {}); +startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); }; +_setWsDragData({dataTransfer: startDt}, item); +// Flag still set (dragend never fired). A foreign text drag arrives with +// DIFFERENT content and no custom MIME. +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': 'some pasted text'}); +dropDt.getData = function(mime){ if(mime==='application/ws-path') return ''; return this._map[mime]||''; }; +process.stdout.write('[' + _wsDragSrcPath({dataTransfer: dropDt}) + ']'); +""" + assert _run_node(js) == '[]', \ + 'stale flag + foreign text (content != tracked path) must resolve to empty' + + def test_clear_ws_drag_data_resets_flags(self): + """_clearWsDragData nulls both flags so a later text/plain-only drag is rejected.""" + js = """\ +const item = {path: 'tmp.txt', type: 'file'}; +const startDt = new FakeDataTransfer([], {}); +startDt.setData = function(mime, val){ this._map[mime]=val; if(!this.types.includes(mime)) this.types.push(mime); }; +_setWsDragData({dataTransfer: startDt}, item); +_clearWsDragData(); +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path}); +process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt}))); +""" + assert _run_node(js) == 'false', \ + '_clearWsDragData must reset flags so a stripped-MIME drag is no longer accepted' + + def test_files_drag_rejected_even_with_active_flag(self): """Files drop must be rejected regardless of active-drag state.""" js = """\