Release v0.51.244 — Release HL (stage-q16) (#3509)
## Release v0.51.244 — Release HL (stage-q16) UX-approved direction via Telegram (workspace drag-drop polish you requested). All 4 drag-drop flows verified live in-browser. ### Added | PR | Author | Feature | |----|--------|---------| | #3402 / #3424 | @pamnard | **Drop OS files/folders onto a specific workspace folder row or breadcrumb** to upload into that directory (not just the current dir). OS folder drops are traversed (`webkitGetAsEntry`/`readEntries`) preserving nested structure. Uploads via the existing `/api/workspace/upload` (no new backend). | ### Fixed - **Composer drop-zone jank**: dragging a workspace file (or OS file) over the composer footer rendered a translucent overlay that let the textarea/chips/icons bleed through and collide with the hint text. Now a clean, fully-opaque box with a single centered **context-aware** label — *"Drop to insert workspace reference"* (workspace file → `@path` insert) vs *"Drop files to attach"* (OS file → message attach). - **Drag-drop handler coexistence (CORE, caught in review)**: #3424's OS-upload binding assigned `el.ondrop` on folder rows, which **overwrote** the drag-to-move handler from #3422 (also `el.ondrop`) — silently breaking move-to-folder (the ws-path drop fell through to the composer as an `@path` insert). Fixed by binding the OS-upload handlers via `addEventListener` so they compose; each handler gates on its own drag type. ### Drag-drop matrix — all verified LIVE in-browser (real drag→drop, asserted on disk) | Flow | Result | |------|--------| | OS image → composer footer | ✓ attaches | | workspace file → composer footer | ✓ inserts `@path` | | workspace file → workspace folder | ✓ moves on disk (report.md → docs/) | | OS file → workspace folder | ✓ uploads into target folder | ### Scope note #3424's PR branch carried the OLD pre-hardening `_handle_file_move`. Applied **frontend-only** — master's hardened move backend (v0.51.243, TOCTOU/symlink fixes) is untouched (Codex confirmed no `api/routes.py` diff). ### Gate - Full pytest suite: **7542 passed, 9 skipped, 3 xpassed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN - Codex (regression): CORE handler-clobber → fixed → **SAFE TO SHIP** Co-authored-by: pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
1 parent
a7f60689a8
commit
c8f8ca3428
8 files changed
+329
-9
No files matched your search
@@ -3,6 +3,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.244] — 2026-06-03 — Release HL (stage-q16 — workspace OS-import drop + composer drop-zone polish)
|
||||
|
||||
### Added
|
||||
- **Drop OS files/folders onto a specific workspace folder row or breadcrumb segment** to upload into that directory (not only the current directory). OS folder drops are traversed via `webkitGetAsEntry`/`readEntries` and their nested structure is preserved on upload. Composer `@path` drags (#1097), the internal tree-move (#3402), and OS-drop isolation (#3411) are all preserved. (#3402, #3424, @pamnard)
|
||||
|
||||
### Fixed
|
||||
- The composer drop-zone overlay no longer looks garbled when you drag a workspace file (or OS file) over the footer. Previously the translucent overlay let the textarea, attach/mic icons, and model/profile chips bleed through and collide with the hint text. The overlay is now a clean, fully-opaque box with a single centered, context-aware label — **"Drop to insert workspace reference"** when dragging a workspace file (which inserts an `@path` reference) vs **"Drop files to attach"** for an OS file (which attaches it to the message).
|
||||
|
||||
## [v0.51.243] — 2026-06-03 — Release HK (stage-q15 — drag-to-move files within the workspace)
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -546,7 +546,7 @@
|
||||
<div class="cmd-dropdown" id="cmdDropdown"></div>
|
||||
<div class="drop-hint" id="dropHint">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Drop files to upload to workspace
|
||||
<span id="dropHintText">Drop files to attach</span>
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
|
||||
+12
-1
@@ -5594,7 +5594,18 @@ async function loadMemory(force) {
|
||||
// Drag and drop
|
||||
const wrap=$('composerWrap');let dragCounter=0;
|
||||
document.addEventListener('dragover',e=>e.preventDefault());
|
||||
document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')||e.dataTransfer.types.includes('application/ws-path')){dragCounter++;wrap.classList.add('drag-over');}});
|
||||
document.addEventListener('dragenter',e=>{e.preventDefault();
|
||||
const isWsPath=e.dataTransfer.types.includes('application/ws-path');
|
||||
const isFiles=e.dataTransfer.types.includes('Files');
|
||||
if(isFiles||isWsPath){
|
||||
dragCounter++;
|
||||
// Context-aware hint: a workspace-file drag inserts an @path reference;
|
||||
// an OS-file drag attaches the file to the message.
|
||||
const hint=$('dropHintText');
|
||||
if(hint) hint.textContent=isWsPath?'Drop to insert workspace reference':'Drop files to attach';
|
||||
wrap.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}});
|
||||
document.addEventListener('drop',e=>{
|
||||
e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');
|
||||
|
||||
+4
-2
@@ -1763,9 +1763,10 @@
|
||||
.composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
|
||||
.composer-box{max-width:clamp(780px,60vw,1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;}
|
||||
.composer-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.composer-wrap.drag-over .composer-box{border-color:var(--accent-text);background:var(--accent-bg);}
|
||||
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:var(--accent-bg);border:2px dashed var(--accent);border-radius:14px;font-size:14px;color:var(--accent-text);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
|
||||
.composer-wrap.drag-over .composer-box{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;gap:10px;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:2px dashed var(--accent);border-radius:16px;font-size:13.5px;font-weight:600;color:var(--accent-text);pointer-events:none;z-index:30;flex-direction:column;text-align:center;padding:8px;}
|
||||
.composer-wrap.drag-over .drop-hint{display:flex;}
|
||||
.drop-hint svg{opacity:.85;}
|
||||
.attach-tray{display:none;flex-wrap:wrap;gap:6px;padding:10px 14px 0;}
|
||||
.attach-tray.has-files{display:flex;}
|
||||
.attach-chip{display:flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--accent-text);}
|
||||
@@ -2095,6 +2096,7 @@
|
||||
.file-item.active{background:var(--accent-bg);color:var(--accent-text);}
|
||||
.file-item.dragging{opacity:.35;border:1px dashed var(--accent-bg-strong);}
|
||||
.file-item.drag-over,.breadcrumb-seg.drag-over{background:var(--accent-bg);color:var(--accent-text);outline:1px solid var(--accent-bg-strong);outline-offset:-1px;}
|
||||
.file-item.drag-over-upload,.breadcrumb-seg.drag-over-upload{background:var(--blue,#3b82f6);color:#fff;outline:1px dashed rgba(255,255,255,.5);outline-offset:-1px;}
|
||||
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:var(--file-tree-toggle-width);text-align:center;line-height:1;}
|
||||
.file-tree-toggle-placeholder{display:inline-block;flex:0 0 var(--file-tree-toggle-width);width:var(--file-tree-toggle-width);line-height:1;}
|
||||
.file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;}
|
||||
|
||||
@@ -8537,6 +8537,7 @@ function renderBreadcrumb(){
|
||||
root.textContent='~';
|
||||
root.onclick=()=>loadDir('.');
|
||||
_bindWorkspaceMoveDropTarget(root,'.');
|
||||
_bindWorkspaceOsUploadDropTarget(root,'.');
|
||||
bar.appendChild(root);
|
||||
// Path segments
|
||||
const parts=S.currentDir.split('/');
|
||||
@@ -8553,6 +8554,7 @@ function renderBreadcrumb(){
|
||||
const target=accumulated;
|
||||
seg.onclick=()=>loadDir(target);
|
||||
_bindWorkspaceMoveDropTarget(seg,target);
|
||||
_bindWorkspaceOsUploadDropTarget(seg,target);
|
||||
} else {
|
||||
seg.className='breadcrumb-seg breadcrumb-current';
|
||||
}
|
||||
@@ -8936,6 +8938,7 @@ function _renderTreeItems(container, entries, depth){
|
||||
const el=document.createElement('div');el.className='file-item';
|
||||
el.style.paddingLeft=(8+depth*16)+'px';
|
||||
el.setAttribute('draggable','true');
|
||||
el.dataset.wsType=item.type;
|
||||
el.oncontextmenu=(e)=>{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();};
|
||||
@@ -9054,6 +9057,7 @@ function _renderTreeItems(container, entries, depth){
|
||||
|
||||
if(item.type==='dir'){
|
||||
_bindWorkspaceMoveDropTarget(el,item.path);
|
||||
_bindWorkspaceOsUploadDropTarget(el,item.path);
|
||||
// Single-click toggles expand/collapse
|
||||
el.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
|
||||
+120
-5
@@ -860,6 +860,121 @@ async function uploadToWorkspace(file, dir) {
|
||||
}
|
||||
}
|
||||
|
||||
function _isOsFilesDrag(e) {
|
||||
return !!(e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files'));
|
||||
}
|
||||
|
||||
function _joinWorkspacePath(base, rel) {
|
||||
const b = base || '.';
|
||||
const r = (rel || '').replace(/^\/+|\/+$/g, '');
|
||||
if (!r) return b;
|
||||
return b === '.' ? r : `${b}/${r}`;
|
||||
}
|
||||
|
||||
function _targetDirForRelDir(destDir, relDir) {
|
||||
const dirPart = (relDir || '').replace(/\/+$/, '');
|
||||
if (!dirPart) return destDir || '.';
|
||||
return _joinWorkspacePath(destDir, dirPart);
|
||||
}
|
||||
|
||||
async function _readAllDirectoryEntries(reader) {
|
||||
const entries = [];
|
||||
while (true) {
|
||||
const batch = await new Promise((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
if (!batch.length) break;
|
||||
entries.push(...batch);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function _collectFilesFromEntry(entry, relPrefix) {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
return [{ file, relDir: relPrefix || '' }];
|
||||
}
|
||||
if (!entry.isDirectory) return [];
|
||||
const reader = entry.createReader();
|
||||
const children = await _readAllDirectoryEntries(reader);
|
||||
const dirPrefix = `${relPrefix || ''}${entry.name}/`;
|
||||
let out = [];
|
||||
for (const child of children) {
|
||||
out = out.concat(await _collectFilesFromEntry(child, dirPrefix));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _collectOsDropUploads(dataTransfer) {
|
||||
const out = [];
|
||||
const items = dataTransfer.items ? [...dataTransfer.items] : [];
|
||||
if (items.length && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (!entry) continue;
|
||||
out.push(...await _collectFilesFromEntry(entry, ''));
|
||||
}
|
||||
if (out.length) return out;
|
||||
}
|
||||
for (const file of dataTransfer.files) {
|
||||
out.push({ file, relDir: '' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function uploadOsDropToWorkspace(dataTransfer, destDir) {
|
||||
if (!S.session || !dataTransfer) return;
|
||||
const uploads = await _collectOsDropUploads(dataTransfer);
|
||||
for (const { file, relDir } of uploads) {
|
||||
await uploadToWorkspace(file, _targetDirForRelDir(destDir, relDir));
|
||||
}
|
||||
if (S.session) await loadDir(S.currentDir);
|
||||
}
|
||||
|
||||
function _clearWorkspaceOsUploadDragOver() {
|
||||
document.querySelectorAll('.file-item.drag-over-upload,.breadcrumb-seg.drag-over-upload').forEach((el) => {
|
||||
el.classList.remove('drag-over-upload');
|
||||
});
|
||||
}
|
||||
|
||||
function _bindWorkspaceOsUploadDropTarget(el, destDir) {
|
||||
// Use addEventListener (not on-property assignment) so these OS-upload
|
||||
// handlers COMPOSE with the workspace tree-MOVE handlers bound by
|
||||
// _bindWorkspaceMoveDropTarget() on the same element. A property assignment
|
||||
// for the drop handler here would overwrite the move handler, and a
|
||||
// workspace-file drag would fall through to the document drop (inserting
|
||||
// @path into the composer) instead of moving the file. Each handler gates on
|
||||
// its own drag type (_isOsFilesDrag vs _isWorkspaceTreeMoveDrag), so only the
|
||||
// matching one acts.
|
||||
el.addEventListener('dragenter', (e) => {
|
||||
if (!_isOsFilesDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.add('drag-over-upload');
|
||||
});
|
||||
el.addEventListener('dragover', (e) => {
|
||||
if (!_isOsFilesDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
el.classList.add('drag-over-upload');
|
||||
});
|
||||
el.addEventListener('dragleave', (e) => {
|
||||
if (el.contains(e.relatedTarget)) return;
|
||||
el.classList.remove('drag-over-upload');
|
||||
});
|
||||
el.addEventListener('drop', async (e) => {
|
||||
if (!_isOsFilesDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.remove('drag-over-upload');
|
||||
await uploadOsDropToWorkspace(e.dataTransfer, destDir);
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop files onto workspace file tree
|
||||
if (typeof document !== 'undefined') {
|
||||
const _wsUploadInit = () => {
|
||||
@@ -875,22 +990,22 @@ if (typeof document !== 'undefined') {
|
||||
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.target.closest('.file-item[data-ws-type="dir"],.breadcrumb-seg')) return;
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
tree.classList.add('drag-over-upload');
|
||||
}
|
||||
});
|
||||
tree.addEventListener('dragleave', () => {
|
||||
tree.addEventListener('dragleave', (e) => {
|
||||
if (tree.contains(e.relatedTarget)) return;
|
||||
tree.classList.remove('drag-over-upload');
|
||||
});
|
||||
tree.addEventListener('drop', async (e) => {
|
||||
tree.classList.remove('drag-over-upload');
|
||||
if (!e.dataTransfer || !e.dataTransfer.types || !e.dataTransfer.types.includes('Files')) return;
|
||||
if (e.target.closest('.file-item[data-ws-type="dir"],.breadcrumb-seg')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
for (const file of e.dataTransfer.files) {
|
||||
await uploadToWorkspace(file, S.currentDir || '.');
|
||||
}
|
||||
if (S.session) loadDir(S.currentDir);
|
||||
await uploadOsDropToWorkspace(e.dataTransfer, S.currentDir || '.');
|
||||
});
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for #3402 part B — OS file/folder import into workspace tree targets."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def _src(name: str) -> str:
|
||||
with open(f"static/{name}") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
WORKSPACE_JS = _src("workspace.js")
|
||||
UI_JS = _src("ui.js")
|
||||
|
||||
|
||||
class TestIssue3402WorkspaceOsImportUi:
|
||||
def test_folder_rows_bind_os_upload_drop(self):
|
||||
assert "_bindWorkspaceOsUploadDropTarget(el,item.path)" in UI_JS
|
||||
|
||||
def test_breadcrumb_binds_os_upload_drop(self):
|
||||
assert "_bindWorkspaceOsUploadDropTarget(root,'.')" in UI_JS
|
||||
assert "_bindWorkspaceOsUploadDropTarget(seg,target)" in UI_JS
|
||||
|
||||
def test_os_upload_helpers_exist(self):
|
||||
assert "function uploadOsDropToWorkspace" in WORKSPACE_JS
|
||||
assert "function _collectOsDropUploads" in WORKSPACE_JS
|
||||
assert "webkitGetAsEntry" in WORKSPACE_JS
|
||||
|
||||
def test_os_folder_drop_stops_propagation(self):
|
||||
block = WORKSPACE_JS[
|
||||
WORKSPACE_JS.index("function _bindWorkspaceOsUploadDropTarget"):
|
||||
WORKSPACE_JS.index("// Drag-and-drop files onto workspace file tree")
|
||||
]
|
||||
assert block.count("e.stopPropagation()") >= 3
|
||||
|
||||
def test_tree_drop_skips_folder_rows(self):
|
||||
assert 'closest(\'.file-item[data-ws-type="dir"]' in WORKSPACE_JS
|
||||
|
||||
def test_file_items_expose_ws_type_dataset(self):
|
||||
assert "el.dataset.wsType=item.type" in UI_JS
|
||||
|
||||
def test_os_upload_highlight_css(self):
|
||||
css = open("static/style.css", encoding="utf-8").read()
|
||||
assert ".file-item.drag-over-upload" in css
|
||||
assert ".breadcrumb-seg.drag-over-upload" in css
|
||||
|
||||
|
||||
def test_join_workspace_path_node():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
return
|
||||
js = r"""
|
||||
const { joinWorkspacePath, targetDirForRelDir } = (() => {
|
||||
function joinWorkspacePath(base, rel) {
|
||||
const b = base || '.';
|
||||
const r = (rel || '').replace(/^\/+|\/+$/g, '');
|
||||
if (!r) return b;
|
||||
return b === '.' ? r : `${b}/${r}`;
|
||||
}
|
||||
function targetDirForRelDir(destDir, relDir) {
|
||||
const dirPart = (relDir || '').replace(/\/+$/, '');
|
||||
if (!dirPart) return destDir || '.';
|
||||
return joinWorkspacePath(destDir, dirPart);
|
||||
}
|
||||
return { joinWorkspacePath, targetDirForRelDir };
|
||||
})();
|
||||
|
||||
const cases = [
|
||||
[joinWorkspacePath('.', ''), '.'],
|
||||
[joinWorkspacePath('docs', ''), 'docs'],
|
||||
[joinWorkspacePath('.', 'docs/reports'), 'docs/reports'],
|
||||
[joinWorkspacePath('src', 'lib/utils'), 'src/lib/utils'],
|
||||
[targetDirForRelDir('projects', ''), 'projects'],
|
||||
[targetDirForRelDir('projects', 'bundle/'), 'projects/bundle'],
|
||||
[targetDirForRelDir('.', 'bundle/sub/'), 'bundle/sub'],
|
||||
];
|
||||
console.log(JSON.stringify(cases.map(([a,b]) => b)));
|
||||
"""
|
||||
out = subprocess.check_output([node, "-e", js], text=True).strip()
|
||||
assert json.loads(out) == [
|
||||
".", "docs", "docs/reports", "src/lib/utils",
|
||||
"projects", "projects/bundle", "bundle/sub",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression: composer drop-zone overlay is clean + context-aware (#3424 polish).
|
||||
|
||||
The composer drop hint used to be a translucent overlay with hardcoded text
|
||||
("Drop files to upload to workspace") — when you dragged a workspace file over
|
||||
the footer, the composer's own controls (textarea, chips, icons) bled through
|
||||
and collided with the hint text, looking garbled. The fix:
|
||||
1. The overlay background is fully opaque (stacked input-bg over --bg), so
|
||||
nothing behind it shows through.
|
||||
2. The hint text is context-aware: a workspace-file drag (application/ws-path,
|
||||
which inserts an @path reference) says "insert workspace reference"; an
|
||||
OS-file drag (which attaches the file) says "attach".
|
||||
|
||||
Source-contract assertions (the project has no JS DOM-test runtime).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_drop_hint_overlay_is_opaque():
|
||||
"""The .drop-hint overlay must use an opaque background (stacked over --bg)
|
||||
so the composer controls behind it cannot bleed through. A bare translucent
|
||||
token like `background:var(--input-bg)` (which is ~0.04 alpha in dark theme)
|
||||
is the regression we're guarding against."""
|
||||
import re
|
||||
m = re.search(r"\.drop-hint\{[^}]*\}", STYLE_CSS)
|
||||
assert m, ".drop-hint rule not found"
|
||||
rule = m.group(0)
|
||||
assert "linear-gradient(var(--input-bg),var(--input-bg)),var(--bg)" in rule, (
|
||||
".drop-hint must use an opaque stacked background so composer controls "
|
||||
"don't bleed through during a drag; got: " + rule
|
||||
)
|
||||
|
||||
|
||||
def test_drop_hint_has_dedicated_text_span():
|
||||
"""The hint text lives in its own #dropHintText span so the handler can
|
||||
swap it per drag type."""
|
||||
assert 'id="dropHintText"' in INDEX_HTML, (
|
||||
"index.html must give the drop-hint label its own #dropHintText span"
|
||||
)
|
||||
# The stale hardcoded copy must be gone.
|
||||
assert "Drop files to upload to workspace" not in INDEX_HTML, (
|
||||
"the old hardcoded drop-hint text should be replaced by the dynamic span"
|
||||
)
|
||||
|
||||
|
||||
def test_drop_hint_text_is_context_aware():
|
||||
"""The composer dragenter handler sets the hint text based on drag type:
|
||||
workspace-file (ws-path) -> insert reference; OS file -> attach."""
|
||||
# Find the dragenter handler block.
|
||||
idx = PANELS_JS.find("addEventListener('dragenter'")
|
||||
assert idx != -1, "composer dragenter handler not found"
|
||||
block = PANELS_JS[idx:idx + 700]
|
||||
assert "application/ws-path" in block, "handler must distinguish the ws-path drag"
|
||||
assert "dropHintText" in block, "handler must update the #dropHintText label"
|
||||
assert "workspace reference" in block, "ws-path drag hint must mention inserting a reference"
|
||||
assert "attach" in block, "OS-file drag hint must mention attaching"
|
||||
|
||||
|
||||
def test_os_upload_binder_composes_not_overwrites_move_binder():
|
||||
"""Regression (Codex CORE catch): _bindWorkspaceOsUploadDropTarget must use
|
||||
addEventListener, NOT el.on*= property assignment. The move-drop binder
|
||||
(_bindWorkspaceMoveDropTarget) and the OS-upload binder run on the SAME
|
||||
folder-row / breadcrumb element; if the OS-upload binder assigned el.ondrop
|
||||
it would clobber the move handler and a workspace-file drag would fall
|
||||
through to the composer (@path insert) instead of moving the file."""
|
||||
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
start = WORKSPACE_JS.find("function _bindWorkspaceOsUploadDropTarget(")
|
||||
assert start != -1, "_bindWorkspaceOsUploadDropTarget not found"
|
||||
# Bound the slice to the end of THIS function: scan brace depth from the
|
||||
# opening '{' so we don't spill into the following top-level code.
|
||||
open_brace = WORKSPACE_JS.find("{", start)
|
||||
depth = 0
|
||||
end = open_brace
|
||||
for i in range(open_brace, len(WORKSPACE_JS)):
|
||||
c = WORKSPACE_JS[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
body = WORKSPACE_JS[start:end]
|
||||
# Must register via addEventListener for drop (composes with move handler)...
|
||||
assert "addEventListener('drop'" in body, (
|
||||
"OS-upload binder must use addEventListener('drop', ...) so it composes "
|
||||
"with the move-drop handler instead of overwriting el.ondrop"
|
||||
)
|
||||
# ...and must NOT use the clobbering property-assignment form.
|
||||
assert "el.ondrop" not in body and "el.ondragover" not in body, (
|
||||
"OS-upload binder must not assign el.on* (it would overwrite the move binder)"
|
||||
)
|
||||
Reference in new issue
Block a user