From 36fee0c6a4beebedd820306ca0ce2d29aa92fd59 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 24 Jun 2026 09:04:01 -0400 Subject: [PATCH] fix(#4757): add active-drag flags so macOS workspace drag-drop lands --- static/ui.js | 32 ++- tests/test_issue4757_macos_drag_drop_mime.py | 212 +++++++++++++++++++ 2 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue4757_macos_drag_drop_mime.py diff --git a/static/ui.js b/static/ui.js index 625e4d1e..412d36bf 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14763,8 +14763,30 @@ function renderFileTree(){ _renderTreeItems(box, visibleEntries, 0); } +let _wsActiveDragPath=null; +let _wsActiveDragType=null; +function _setWsDragData(e,item){ + e.dataTransfer.setData('application/ws-path',item.path); + e.dataTransfer.setData('application/ws-type',item.type); + e.dataTransfer.setData('text/plain',item.path); + _wsActiveDragPath=item.path; + _wsActiveDragType=item.type; +} function _isWorkspaceTreeMoveDrag(e){ - return !!(e.dataTransfer&&e.dataTransfer.types&&e.dataTransfer.types.includes('application/ws-path')&&!e.dataTransfer.types.includes('Files')); + 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; + 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')||''; +} +function _wsDragSrcType(e){ + const custom=e.dataTransfer.getData('application/ws-type'); + if(custom) return custom; + return _wsActiveDragType||'file'; } function _workspaceParentDir(relPath){ @@ -14850,9 +14872,9 @@ function _bindWorkspaceMoveDropTarget(el,destDir){ if(!_isWorkspaceTreeMoveDrag(e))return; e.preventDefault();e.stopPropagation(); el.classList.remove('drag-over'); - const srcPath=e.dataTransfer.getData('application/ws-path'); + const srcPath=_wsDragSrcPath(e); if(!srcPath)return; - const srcType=e.dataTransfer.getData('application/ws-type'); + const srcType=_wsDragSrcType(e); await _performWorkspaceMove(srcPath,destDir,srcType==='dir'); }; } @@ -14875,8 +14897,8 @@ function _renderTreeItems(container, entries, depth){ if(grant&&!isDirRow){e.preventDefault();e.stopPropagation();return;} e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item); }; - el.ondragstart=(e)=>{e.dataTransfer.setData('application/ws-path',item.path);e.dataTransfer.setData('application/ws-type',item.type);e.dataTransfer.effectAllowed='copy';el.classList.add('dragging');}; - el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();}; + 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;}; 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 new file mode 100644 index 00000000..d6b6bf01 --- /dev/null +++ b/tests/test_issue4757_macos_drag_drop_mime.py @@ -0,0 +1,212 @@ +"""Regression tests for #4757 — macOS workspace drag-drop never lands. + +WebKit strips custom MIME types from DataTransfer during dragover/drop, so +_isWorkspaceTreeMoveDrag must accept text/plain when the module-scoped active-drag +flag is set. The node-driver extraction pattern runs all logic in Node.js without +a browser. +""" +import os +import subprocess +import tempfile + +UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js') + + +def _extract_drag_functions(): + """Extract the six drag-drop functions from ui.js.""" + src = open(UI_JS, encoding='utf-8').read() + + # Extract module-scoped let declarations + lines = src.split('\n') + let_lines = [] + for line in lines: + s = line.strip() + if s in ('let _wsActiveDragPath=null;', 'let _wsActiveDragType=null;'): + let_lines.append(s) + + def extract_function(name): + start = src.find(f'function {name}(') + if start < 0: + raise AssertionError(f'{name} not found in ui.js') + i = src.find('{', start) + depth = 1 + i += 1 + while i < len(src) and depth: + if src[i] == '{': + depth += 1 + elif src[i] == '}': + depth -= 1 + i += 1 + return src[start:i] + + fns = '\n'.join( + extract_function(name) + for name in ( + '_setWsDragData', + '_isWorkspaceTreeMoveDrag', + '_wsDragSrcPath', + '_wsDragSrcType', + ) + ) + return '\n'.join(let_lines), fns + + +def _run_node(test_js): + """Run a Node.js snippet that includes the extracted functions, return stdout.""" + lets, fns = _extract_drag_functions() + harness = """\ +// Fake DataTransfer +class FakeDataTransfer { + constructor(typesArr, dataMap) { + this._map = dataMap || {}; + this.types = typesArr || Object.keys(this._map); + } + getData(mime) { return this._map[mime] || ''; } + setData(mime, val) { this._map[mime] = val; if(!this.types.includes(mime)) this.types.push(mime); } +} +""" + js_code = harness + '\n' + lets + '\n' + fns + '\n' + test_js + + tf = tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False, encoding='utf-8') + tf.write(js_code) + tf.close() + try: + result = subprocess.run( + ['node', tf.name], + capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + raise RuntimeError(f'node error: {result.stderr}') + return result.stdout.strip() + finally: + os.unlink(tf.name) + + +class TestMacosDragDropMime: + """Module-scoped active-drag flags allow workspace drag-drop on macOS.""" + + def test_macos_stripped_drag_accepted(self): + """After _setWsDragData, gate accepts text/plain-only (WebKit strips custom MIME).""" + 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); + +// Simulate macOS: only text/plain survives into dragover/drop +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path}); +const accepted = _isWorkspaceTreeMoveDrag({dataTransfer: dropDt}); +process.stdout.write(String(accepted)); +""" + assert _run_node(js) == 'true', \ + 'macOS-stripped drag must be accepted when active-drag flag is set' + + def test_normal_drag_accepted(self): + """Custom MIME present — gate returns true without needing the flag.""" + js = """\ +const dropDt = new FakeDataTransfer( + ['application/ws-path', 'text/plain'], + {'application/ws-path': 'notes.txt', 'text/plain': 'notes.txt'} +); +process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt}))); +""" + assert _run_node(js) == 'true', \ + 'normal drag (custom MIME present) must be accepted' + + def test_foreign_text_drag_rejected(self): + """Foreign text (text/plain only, no active-drag flag) must be rejected.""" + js = """\ +// Do NOT call _setWsDragData — flag stays null +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': 'hello world'}); +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_files_drag_rejected_even_with_active_flag(self): + """Files drop must be rejected regardless of active-drag state.""" + js = """\ +const item = {path: 'img.png', 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); + +const dropDt = new FakeDataTransfer(['Files', 'text/plain'], {}); +process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt}))); +""" + assert _run_node(js) == 'false', \ + 'Files drop must be rejected even when active-drag flag is set' + + def test_resolver_returns_custom_mime_when_present(self): + """_wsDragSrcPath returns custom MIME value when available.""" + js = """\ +const dropDt = new FakeDataTransfer( + ['application/ws-path', 'text/plain'], + {'application/ws-path': 'src/index.js', 'text/plain': 'fallback'} +); +process.stdout.write(_wsDragSrcPath({dataTransfer: dropDt})); +""" + assert _run_node(js) == 'src/index.js', \ + '_wsDragSrcPath must prefer custom MIME over text/plain' + + def test_resolver_falls_back_to_active_drag_path(self): + """_wsDragSrcPath falls back to active-drag flag when custom MIME stripped.""" + js = """\ +const item = {path: 'my file.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); + +// Custom MIME stripped; text/plain has the path too (as set by _setWsDragData) +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path}); +// Override getData so custom MIME returns empty +dropDt.getData = function(mime){ if(mime==='application/ws-path') return ''; return this._map[mime]||''; }; +process.stdout.write(_wsDragSrcPath({dataTransfer: dropDt})); +""" + assert _run_node(js) == 'my file.txt', \ + '_wsDragSrcPath must fall back to active-drag flag (supports paths with spaces)' + + def test_resolver_type_returns_custom_mime_type(self): + """_wsDragSrcType returns custom type when available.""" + js = """\ +const dropDt = new FakeDataTransfer( + ['application/ws-type'], + {'application/ws-type': 'dir'} +); +process.stdout.write(_wsDragSrcType({dataTransfer: dropDt})); +""" + assert _run_node(js) == 'dir', \ + '_wsDragSrcType must return custom MIME type value' + + def test_resolver_type_falls_back_to_active_drag_type(self): + """_wsDragSrcType falls back to active-drag flag when custom MIME stripped.""" + js = """\ +const item = {path: 'projects/', type: 'dir'}; +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); + +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path}); +dropDt.getData = function(mime){ if(mime==='application/ws-type') return ''; return this._map[mime]||''; }; +process.stdout.write(_wsDragSrcType({dataTransfer: dropDt})); +""" + assert _run_node(js) == 'dir', \ + '_wsDragSrcType must fall back to active-drag type flag' + + def test_ondragend_equivalent_clears_state(self): + """Clearing flags causes text/plain-only drag to be rejected (ondragend effect).""" + 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); + +// Simulate ondragend clearing flags +_wsActiveDragPath = null; +_wsActiveDragType = null; + +const dropDt = new FakeDataTransfer(['text/plain'], {'text/plain': item.path}); +process.stdout.write(String(_isWorkspaceTreeMoveDrag({dataTransfer: dropDt}))); +""" + assert _run_node(js) == 'false', \ + 'after ondragend clears flags, text/plain-only drag must be rejected'