fix(#4757): add active-drag flags so macOS workspace drag-drop lands

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-24 18:58:33 +00:00
1 parent f61de2a178
commit 36fee0c6a4
2 files changed
+239 -5

No files matched your search

+27 -5
View File
@@ -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;
@@ -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'