chore: merge executor worktree (10-10 OS drag overlay)

This commit is contained in:
curo1305
2026-06-15 20:48:00 +02:00
5 changed files with 362 additions and 8 deletions
@@ -0,0 +1,124 @@
---
phase: 10-ux-interaction
plan: "10"
subsystem: frontend/ux
tags: [os-drag, overlay, depth-counter, teleport, vitest, tdd, ux-09, wave-3]
dependency_graph:
requires: [10-04, 10-05, 10-06, 10-09]
provides: [UX-09-os-drag-overlay, OsDragOverlay-component, App.vue-osDrop-wiring, FileManagerView-handleOsDrop]
affects: [App.vue, FileManagerView.vue, OsDragOverlay.vue, OsDragOverlay.test.js]
tech_stack:
added: []
patterns: [depth-counter-drag-detection, teleport-to-body, window-event-listeners, options-api-component, tdd-red-green]
key_files:
created:
- frontend/src/components/layout/OsDragOverlay.vue
modified:
- frontend/src/components/layout/__tests__/OsDragOverlay.test.js
- frontend/src/App.vue
- frontend/src/views/FileManagerView.vue
decisions:
- "Depth-counter pattern (dragDepth++) used instead of boolean flag to prevent flicker when cursor crosses child element boundaries"
- "dataTransfer.types.includes('Files') guard ensures in-app drags (file rows, folder rows) do not trigger the overlay"
- "OsDragOverlay placed after ToastContainer in App.vue template, maintaining z-[9998] < z-[9999] z-order invariant"
- "handleOsDrop delegates to existing onFilesSelected rather than duplicating upload logic — quota, progress, toast wiring all reused"
- "node_modules symlinked/installed in worktree frontend to make vitest available without polluting main checkout"
metrics:
duration_minutes: 6
completed_date: "2026-06-15T18:46:33Z"
tasks_completed: 3
tasks_total: 3
files_created: 1
files_modified: 3
---
# Phase 10 Plan 10: OS File Drag Overlay Summary
**One-liner:** Full-screen OS drag overlay (OsDragOverlay.vue) wired end-to-end via depth-counter pattern: window dragenter/drop events in App.vue route through routeViewRef to FileManagerView.handleOsDrop which calls the existing upload flow.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Promote OsDragOverlay stubs to 8 RED failing tests | 71f55b8 | `layout/__tests__/OsDragOverlay.test.js` |
| 2 | Implement OsDragOverlay.vue (GREEN) | 20eceb8 | `layout/OsDragOverlay.vue` |
| 3 | Mount in App.vue; expose handleOsDrop in FileManagerView | 69bf40a | `App.vue`, `FileManagerView.vue` |
## What Was Built
### Task 1: RED Tests
Replaced 7 `it.todo` stubs in `OsDragOverlay.test.js` with 8 real tests. The plan specified 8 tests while the stub file had 7 entries; the extra test covers "nested dragenter+dragleave" (depth-2 scenario, critical for the depth-counter correctness). Tests use synthetic DragEvent / Event dispatched on `window` with `Object.defineProperty` to attach fake `dataTransfer`. All 8 were RED before Task 2.
### Task 2: OsDragOverlay.vue
Options API component implementing UX-09:
- `data`: `dragDepth: 0`, `showOverlay: false`
- `onDragEnter`: ignores events where `e.dataTransfer?.types.includes('Files')` is false (in-app drags); increments `dragDepth` and sets `showOverlay = true`
- `onDragLeave`: decrements via `Math.max(0, dragDepth - 1)`; hides when depth reaches 0
- `onDragOver`: calls `e.preventDefault()` (required for drop to fire)
- `onDrop`: resets depth=0, hides overlay, emits `files-dropped` with `Array.from(e.dataTransfer.files)`
- `mounted()`/`beforeUnmount()`: register/remove 4 window listeners
- Template: `<Teleport to="body">` + `<Transition name="fade">` + overlay div with `z-[9998]` and `data-test="os-drag-overlay"`
- Scoped CSS: `.fade-enter-active/.fade-leave-active` with `transition: opacity 0.15s ease`
### Task 3: Wiring
**App.vue:**
- Added `import OsDragOverlay from './components/layout/OsDragOverlay.vue'`
- Added `<OsDragOverlay @files-dropped="onOsFilesDropped" />` immediately after `<ToastContainer />` (line 10 vs line 9)
- Added `function onOsFilesDropped(files) { routeViewRef.value?.handleOsDrop?.(files) }` — double optional chain: no-ops on non-file-manager routes where `handleOsDrop` is not exposed
**FileManagerView.vue:**
- Extended `defineExpose` block with `handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true })`
- No duplicate upload logic — existing `onFilesSelected` function handles per-file UploadProgress items, quota error detection (e.status 413), and the summary toast
## Verification Results
| Check | Result |
|-------|--------|
| `vitest run OsDragOverlay` — 8 tests | PASS (all GREEN) |
| `vitest run keyboard` — 9 tests | PASS (no regression) |
| `vitest run FileManagerView` — 20 tests | PASS (no regression) |
| Full suite — 198 tests, 13 todo | PASS (0 failures) |
| OsDragOverlay placed AFTER ToastContainer | PASS (line 10 vs line 9) |
| OsDragOverlay z-[9998] < ToastContainer z-[9999] | PASS |
| FileManagerView.handleOsDrop reuses onFilesSelected | PASS |
| `routeViewRef.value?.handleOsDrop?.(files)` in App.vue | PASS |
| No duplicate upload logic | PASS |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] node_modules not available in git worktree**
- **Found during:** Task 1
- **Issue:** The worktree has no `node_modules` directory; `npm run test` (using the global vitest) and the main repo's vitest binary both fail to resolve imports when the test root points at the worktree
- **Fix:** Ran `npm install` in the worktree's `frontend/` directory to create a local `node_modules` matching the `package.json`. Tests then run correctly with `worktree/frontend/node_modules/.bin/vitest`
- **Files modified:** none (runtime setup only)
- **Commit:** none (npm install not committed — `node_modules` is gitignored)
## Known Stubs
None. All files created and modified in this plan have complete implementations with no placeholder values, TODO markers, or hardcoded empty data flowing to the UI.
## Threat Flags
None. OsDragOverlay handles OS file drop at the browser level and delegates to the existing `onFilesSelected``docsStore.upload` path. No new network endpoints, auth paths, or schema changes are introduced. The existing upload path has quota enforcement, JWT auth headers, and error handling already in place.
## TDD Gate Compliance
- RED gate: commit `71f55b8` — 8 failing tests (`test(10-10): promote OsDragOverlay stubs...`)
- GREEN gate: commit `20eceb8` — implementation passes 8 tests (`feat(10-10): implement OsDragOverlay.vue...`)
- REFACTOR gate: not required (no cleanup needed after GREEN)
## Self-Check: PASSED
- `frontend/src/components/layout/OsDragOverlay.vue` — exists, contains `name: 'OsDragOverlay'`, `emits: ['files-dropped']`, `dragDepth: 0`, `dataTransfer?.types.includes('Files')`, 4 window listeners, `<Teleport to="body">`, `z-[9998]`, no `<script setup>`
- `frontend/src/App.vue` — contains `import OsDragOverlay`, `<OsDragOverlay @files-dropped="onOsFilesDropped" />` after `<ToastContainer />`, `onOsFilesDropped` handler, `routeViewRef.value?.handleOsDrop?.(files)`
- `frontend/src/views/FileManagerView.vue``handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true })` in defineExpose
- Commit 71f55b8 — confirmed in git log
- Commit 20eceb8 — confirmed in git log
- Commit 69bf40a — confirmed in git log
- No unexpected file deletions in any commit
+6
View File
@@ -7,6 +7,7 @@
</main> </main>
</div> </div>
<ToastContainer /> <ToastContainer />
<OsDragOverlay @files-dropped="onOsFilesDropped" />
</template> </template>
<script setup> <script setup>
@@ -15,12 +16,17 @@ import { useRoute } from 'vue-router'
import AppSidebar from './components/layout/AppSidebar.vue' import AppSidebar from './components/layout/AppSidebar.vue'
import AuthLayout from './layouts/AuthLayout.vue' import AuthLayout from './layouts/AuthLayout.vue'
import ToastContainer from './components/ui/ToastContainer.vue' import ToastContainer from './components/ui/ToastContainer.vue'
import OsDragOverlay from './components/layout/OsDragOverlay.vue'
import { useTopicsStore } from './stores/topics.js' import { useTopicsStore } from './stores/topics.js'
const route = useRoute() const route = useRoute()
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const routeViewRef = ref(null) const routeViewRef = ref(null)
function onOsFilesDropped(files) {
routeViewRef.value?.handleOsDrop?.(files)
}
function onKeydown(e) { function onKeydown(e) {
const tag = document.activeElement?.tagName const tag = document.activeElement?.tagName
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return
@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="showOverlay"
class="fixed inset-0 z-[9998] bg-indigo-900/40 flex items-center justify-center pointer-events-none"
data-test="os-drag-overlay"
>
<div class="bg-white rounded-2xl px-10 py-8 text-center shadow-xl pointer-events-none">
<AppIcon name="upload" class="w-10 h-10 text-indigo-400 mx-auto mb-3" />
<p class="text-base font-semibold text-gray-800">Drop files to upload</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script>
import AppIcon from '../ui/AppIcon.vue'
export default {
name: 'OsDragOverlay',
components: { AppIcon },
emits: ['files-dropped'],
data() {
return {
dragDepth: 0,
showOverlay: false,
}
},
methods: {
onDragEnter(e) {
if (!e.dataTransfer?.types.includes('Files')) return
this.dragDepth++
this.showOverlay = true
},
onDragLeave() {
this.dragDepth = Math.max(0, this.dragDepth - 1)
if (this.dragDepth === 0) this.showOverlay = false
},
onDragOver(e) {
e.preventDefault()
},
onDrop(e) {
e.preventDefault()
this.dragDepth = 0
this.showOverlay = false
const files = Array.from(e.dataTransfer.files)
if (files.length) this.$emit('files-dropped', files)
},
},
mounted() {
window.addEventListener('dragenter', this.onDragEnter)
window.addEventListener('dragleave', this.onDragLeave)
window.addEventListener('dragover', this.onDragOver)
window.addEventListener('drop', this.onDrop)
},
beforeUnmount() {
window.removeEventListener('dragenter', this.onDragEnter)
window.removeEventListener('dragleave', this.onDragLeave)
window.removeEventListener('dragover', this.onDragOver)
window.removeEventListener('drop', this.onDrop)
},
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -1,11 +1,158 @@
import { describe, it } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import OsDragOverlay from '../OsDragOverlay.vue'
function makeDragEvent(type, types = ['Files'], files = []) {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', {
value: { types, files, preventDefault: vi.fn() },
writable: false,
})
event.preventDefault = vi.fn()
return event
}
describe('UX-09: OsDragOverlay shows when OS files are dragged over the window', () => { describe('UX-09: OsDragOverlay shows when OS files are dragged over the window', () => {
it.todo('overlay hidden by default (dragDepth=0)') it('overlay hidden by default (dragDepth=0)', () => {
it.todo('dragenter with dataTransfer.types including "Files" shows overlay') const w = mount(OsDragOverlay, {
it.todo('dragenter without "Files" type is ignored (in-app element drag)') attachTo: document.body,
it.todo('dragleave decrements depth; overlay hides when depth reaches 0') global: { stubs: { Teleport: true, AppIcon: true } },
it.todo('drop event emits files-dropped with the file list') })
it.todo('drop resets dragDepth to 0 and hides overlay') expect(w.vm.showOverlay).toBe(false)
it.todo('overlay is teleported to body with z-[9998] (below toast z-[9999])') expect(w.vm.dragDepth).toBe(0)
w.unmount()
})
it('dragenter with dataTransfer.types including "Files" shows overlay (dragDepth=1)', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
global: { stubs: { Teleport: true, AppIcon: true } },
})
const event = makeDragEvent('dragenter', ['Files'])
window.dispatchEvent(event)
await w.vm.$nextTick()
expect(w.vm.showOverlay).toBe(true)
expect(w.vm.dragDepth).toBe(1)
w.unmount()
})
it('dragenter without "Files" type is ignored (in-app element drag)', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
global: { stubs: { Teleport: true, AppIcon: true } },
})
const event = makeDragEvent('dragenter', ['text/plain'])
window.dispatchEvent(event)
await w.vm.$nextTick()
expect(w.vm.showOverlay).toBe(false)
expect(w.vm.dragDepth).toBe(0)
w.unmount()
})
it('dragleave decrements depth; overlay hides when depth reaches 0', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
global: { stubs: { Teleport: true, AppIcon: true } },
})
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(1)
expect(w.vm.showOverlay).toBe(true)
window.dispatchEvent(new Event('dragleave'))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(0)
expect(w.vm.showOverlay).toBe(false)
w.unmount()
})
it('nested dragenter+dragleave maintains overlay until depth=0', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
global: { stubs: { Teleport: true, AppIcon: true } },
})
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(2)
expect(w.vm.showOverlay).toBe(true)
window.dispatchEvent(new Event('dragleave'))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(1)
expect(w.vm.showOverlay).toBe(true)
window.dispatchEvent(new Event('dragleave'))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(0)
expect(w.vm.showOverlay).toBe(false)
w.unmount()
})
it('drop event emits files-dropped with the file list', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
global: { stubs: { Teleport: true, AppIcon: true } },
})
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
await w.vm.$nextTick()
const file = new File(['x'], 'a.txt', { type: 'text/plain' })
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
writable: false,
})
dropEvent.preventDefault = vi.fn()
window.dispatchEvent(dropEvent)
await w.vm.$nextTick()
const emitted = w.emitted('files-dropped')
expect(emitted).toBeTruthy()
expect(emitted[0][0]).toHaveLength(1)
expect(emitted[0][0][0]).toBeInstanceOf(File)
w.unmount()
})
it('drop resets dragDepth to 0 and hides overlay', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
global: { stubs: { Teleport: true, AppIcon: true } },
})
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(3)
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [] },
writable: false,
})
dropEvent.preventDefault = vi.fn()
window.dispatchEvent(dropEvent)
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(0)
expect(w.vm.showOverlay).toBe(false)
window.dispatchEvent(new Event('dragleave'))
await w.vm.$nextTick()
expect(w.vm.dragDepth).toBe(0)
w.unmount()
})
it('overlay is teleported to body with z-[9998] (below toast z-[9999])', async () => {
const w = mount(OsDragOverlay, {
attachTo: document.body,
})
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
await w.vm.$nextTick()
const overlay = document.querySelector('[data-test="os-drag-overlay"]')
expect(overlay).toBeTruthy()
expect(overlay.classList.contains('z-[9998]')).toBe(true)
w.unmount()
})
}) })
+1
View File
@@ -186,5 +186,6 @@ defineExpose({
triggerUpload: () => browserRef.value?.triggerUpload?.(), triggerUpload: () => browserRef.value?.triggerUpload?.(),
startNewFolder: () => browserRef.value?.startNewFolder?.(), startNewFolder: () => browserRef.value?.startNewFolder?.(),
clearSearch: () => browserRef.value?.clearSearch?.(), clearSearch: () => browserRef.value?.clearSearch?.(),
handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }),
}) })
</script> </script>