Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion frontend/e2e/atlas-paste-convert.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test, expect } from './fixtures/server'
import { deleteViaPageMenu } from './fixtures/atlasPage'
import { openCard } from './fixtures/atlasBoard'
import { closeCard, deleteSticky, openCard } from './fixtures/atlasBoard'
import { clickRowAction } from './inventoryRow'
import { contextMenu } from './fixtures/contextMenu'

Expand Down Expand Up @@ -54,6 +57,19 @@ async function pasteHTML(page: import('@playwright/test').Page, html: string, pl
}, { html, text: plainTextSibling })
}

// pastePlainText injects text/plain VERBATIM (unlike pasteText below,
// which URI-encodes to mimic the diagram tool's own copy format) --
// the shape a copied file path or ordinary prose actually has.
async function pastePlainText(page: import('@playwright/test').Page, raw: string) {
// eslint-disable-next-line no-restricted-syntax -- cursor-position-only gesture, not a checkable interaction (pasteText's own comment below has the full reasoning)
await page.mouse.move(1000, 220)
await page.evaluate((t) => {
const dt = new DataTransfer()
dt.setData('text/plain', t)
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
}, raw)
}

async function pasteText(page: import('@playwright/test').Page, raw: string) {
// The paste anchors at the pointer (a paste inside a frame files
// into it, by design) -- aim at open canvas so the entities land at
Expand Down Expand Up @@ -186,3 +202,90 @@ test('a table copied from an M365 app (HTML clipboard flavor) lands a board-loca
await clickRowAction(page, listRow, 'Delete')
await expect(listRow).toHaveCount(0)
})

// Regression: pasting a local file PATH (text) landed a sticky note
// containing the raw path string, while DROPPING the same file landed
// the real thing (goal 0179's founding rule). A pasted path now routes
// through the drop door's own landing pipeline: .md becomes a mirrored
// document card, .drawio a diagram board object -- and a path that
// doesn't resolve on disk still falls back to the note, never a dead
// end.
test('pasting a file path lands what dropping the file would; a dead path still falls back to a note', async ({ page }) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mill-e2e-paste-path-'))
const mdPath = path.join(dir, 'ZzE2ePastedDocPath.md')
fs.writeFileSync(mdPath, '# Pasted doc\n\nbody\n')
const drawioPath = path.join(dir, 'ZzE2ePastedDiagramPath.drawio')
fs.writeFileSync(drawioPath, '<mxfile><diagram name="P">'
+ '<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>'
+ '<mxCell id="2" value="Box" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40"/></mxCell>'
+ '</root></mxGraphModel></diagram></mxfile>')
try {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()

// .md path -> mirrored document card, exactly like dropping the file.
await pastePlainText(page, mdPath)
const card = page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDocPath' })
await expect(card).toBeVisible()
await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2ePastedDocPath' })).toHaveCount(0)

// .drawio path -> diagram board object, never a card or note.
await pastePlainText(page, drawioPath)
const diagram = page.locator('[data-testid="atlas-board-object"][data-object-kind="diagram"]')
await expect(diagram).toHaveCount(1)
await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDiagramPath' })).toHaveCount(0)

// A path-shaped string that doesn't exist stays ordinary text: the
// note fallback, so nothing a user pastes ever vanishes.
const deadPath = path.join(dir, 'ZzE2eDeadPath.md')
await pastePlainText(page, deadPath)
const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eDeadPath' })
await expect(note).toBeVisible()

// Cleanup (shared pool): note, diagram object, card.
await deleteSticky(page, note)
await deleteObjectViaMenu(diagram)
await expect(diagram).toHaveCount(0)
await card.click({ button: 'right' })
const menu = contextMenu(page)
await expect(menu).toBeVisible()
await menu.getByText('Delete', { exact: true }).click()
await expect(card).toHaveCount(0)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})

// Regression: the board's window-level paste door stayed live while a
// card page (a modal dialog) covered the board -- pasting with focus
// on no field landed a sticky note INVISIBLY behind the dialog. The
// door now stands down while a modal surface is open, and comes back
// the moment it closes.
test('pasting while a card page is open lands nothing behind it; the door returns on close', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()

const card = page.getByTestId('atlas-note-card').filter({ hasText: 'Discovery workstream' }).first()
await openCard(page, card)
const overlay = page.locator('[data-component="atlas-card-overlay"]')
await expect(overlay).toBeVisible()
// Land focus on nothing editable: click the page's own header region
// (the real state a user reaches by clicking any non-field area).
await page.getByTestId('atlas-page-header').click()

await pastePlainText(page, 'ZzE2eModalGateProbe')
// No observable "nothing happened" signal exists to await -- a fixed
// settle window is the only way to assert the note did NOT land.
await page.waitForTimeout(800) // asserting a no-op: no observable condition exists to await
await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })).toHaveCount(0)

await closeCard(page, overlay)

// The same paste with the board foreground again lands its note.
await pastePlainText(page, 'ZzE2eModalGateProbe')
const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })
await expect(note).toBeVisible()
await deleteSticky(page, note)
})
22 changes: 3 additions & 19 deletions frontend/src/atlas/AtlasBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import { useRenderStormGuard } from '../shared/renderStormGuard'
import { useAtlasDeleteKey } from './useAtlasDeleteKey'
import { useTranslation } from 'react-i18next'
import { ReactFlow, ReactFlowProvider, useNodesState, useReactFlow } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
Expand Down Expand Up @@ -126,24 +127,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
const selection = useAtlasSelection({ cards, notes, objects, onMultiSelectContextMenu })
const wrapperClicks = useAtlasPaneClick({ tablePicker, topLevelBoxes, screenToFlowPosition, onCreateTableSized, placeAt: creation.placeAt })

// Delete/Backspace over a live selection -> the shared confirm
// (never fires from editable elements; single or multi).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return
const el = document.activeElement
if (el instanceof HTMLElement && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
const sel = selection.selectedIDsRef.current
if (sel.length === 0) return
e.preventDefault()
const cardIDs = sel.filter((id) => cards.some((c) => c.ID === id))
const noteIDs = sel.filter((id) => notes.some((n) => n.ID === id))
const objectIDs = sel.filter((id) => objects.some((o) => o.ID === id))
if (cardIDs.length + noteIDs.length + objectIDs.length > 0) onDeleteSelection(cardIDs, noteIDs, objectIDs)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [cards, notes, objects, onDeleteSelection, selection.selectedIDsRef])
useAtlasDeleteKey({ cards, notes, objects, selectedIDsRef: selection.selectedIDsRef, onDeleteSelection })

// Zoom chip / group-header click / Enter on a region frame (routed
// here through AtlasGroupNode's own data.onDrill) all fly the camera
Expand Down Expand Up @@ -183,7 +167,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f

// The capture doors (goal 0081 slice A3): own hook files, 500-line cap.
const fileDrop = useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPosition, setPulsedID, reduceMotion })
useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated: selection.selectNote })
useAtlasPaste({ topLevelBoxes, screenToFlowPosition, viewedID, onPasteConverted, onNoteCreated: selection.selectNote, landFiles: fileDrop.landFiles })
useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCardIDs: selection.selectedCards, selectedNoteIDs: selection.selectedNotes, topLevelBoxes, screenToFlowPosition, viewedID, readOnly, showToast: onQuietToast })

// Handle honesty: no kind restricts linking, so zero legal targets means a board with nothing else on it.
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/atlas/atlasCreateHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { imagePathFromClipboardText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
import { imagePathFromClipboardText, localPathFromPastedText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'

function kind(id: string): Kind {
Expand Down Expand Up @@ -167,3 +167,43 @@ describe('imagePathFromClipboardText', () => {
expect(imagePathFromClipboardText('', ' ')).toBeNull()
})
})

describe('localPathFromPastedText', () => {
// The board paste door's file-drop gate: pasting a local file path
// must route like dropping that file, and nothing else may.
it('accepts an absolute path', () => {
expect(localPathFromPastedText('/Users/me/notes/plan.md')).toBe('/Users/me/notes/plan.md')
})

it('accepts a quoted absolute path', () => {
expect(localPathFromPastedText('"/Users/me/My Docs/plan.md"')).toBe('/Users/me/My Docs/plan.md')
})

it('accepts a file:// URL, percent-decoded', () => {
expect(localPathFromPastedText('file:///Users/me/My%20Docs/plan.md')).toBe('/Users/me/My Docs/plan.md')
})

it('accepts a directory path (no extension required)', () => {
expect(localPathFromPastedText('/Users/me/project')).toBe('/Users/me/project')
})

it('rejects multi-line text even when the first line is a path', () => {
expect(localPathFromPastedText('/Users/me/plan.md\nand more prose')).toBeNull()
})

it('rejects relative paths', () => {
expect(localPathFromPastedText('notes/plan.md')).toBeNull()
})

it('rejects http URLs', () => {
expect(localPathFromPastedText('https://example.com/plan.md')).toBeNull()
})

it('rejects ordinary prose', () => {
expect(localPathFromPastedText('meet at the usual place')).toBeNull()
})

it('rejects empty and whitespace text', () => {
expect(localPathFromPastedText(' ')).toBeNull()
})
})
18 changes: 18 additions & 0 deletions frontend/src/atlas/atlasCreateHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ export function imagePathFromClipboardText(...candidates: string[]): string | nu
return null
}

// localPathFromPastedText resolves a pasted text/plain clipboard into
// a local ABSOLUTE file path, or null when the text isn't path-shaped
// -- the board paste door's gate before treating a paste as a file
// drop. Deliberately stricter than imagePathFromClipboardText above:
// the WHOLE text must be one line (the first line of pasted prose must
// never silently swallow the rest), and only absolute paths qualify.
// Wrapping quotes are stripped (a terminal-copied path) and file://
// URLs normalize through the same helper the picker uses. SHAPE only:
// whether the path actually exists is the backend's decision
// (ResolveFileDropRoute), and a dead path falls back to ordinary text.
export function localPathFromPastedText(text: string): string | null {
const trimmed = text.trim()
if (trimmed === '' || /[\r\n]/.test(trimmed)) return null
const unquoted = /^(['"]).*\1$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed
const path = normalizeLocalPathInput(unquoted)
return path.startsWith('/') ? path : null
}

// resolveNoteCommitText decides whether a re-edited note's text should
// persist, and exactly what to persist (goal 0226's round-trip
// contract): null skips the write entirely (an existing note's own
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/atlas/useAtlasClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { childrenOf } from './atlasGrouping'
import { frameContainingPoint } from './atlasFramePoint'
import type { FrameBox } from './useAtlasDragFiling'
import { parseAtlasClonePayload, serializeAtlasSelection, type AtlasClonePayload } from './atlasClipboard'
import { modalSurfaceOpen } from '../shared/modalGate'

// createClones performs the payload's writes: cards parents-before-
// children (co-copied structure re-parents onto fresh clone ids, pass
Expand Down Expand Up @@ -83,6 +84,9 @@ export function useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCa

const onCopy = (e: ClipboardEvent) => {
const s = stateRef.current
// A modal above the board owns the screen: copying here would
// silently overwrite the clipboard with a HIDDEN board selection.
if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
if (window.getSelection()?.toString()) return
const payload = serializeAtlasSelection(s.allCards, s.allNotes, s.links, s.selectedCardIDs, s.selectedNoteIDs)
Expand Down Expand Up @@ -138,6 +142,9 @@ export function useAtlasClipboard({ allCards, allNotes, links, kinds, selectedCa
const onPaste = (e: ClipboardEvent) => {
const s = stateRef.current
if (s.readOnly) return
// Same modal stand-down as onCopy: pasted clones would land
// invisibly behind the open dialog.
if (modalSurfaceOpen()) return
if (isEditableTarget(document.activeElement)) return
const payload = parseAtlasClonePayload(e.clipboardData?.getData('text/plain') ?? '')
if (!payload) return
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/atlas/useAtlasDeleteKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, type RefObject } from 'react'
import type { Card, Note, BoardObject } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
import { modalSurfaceOpen } from '../shared/modalGate'

// Delete/Backspace over a live board selection -> the shared
// delete path (quick-delete-with-undo for notes/objects, the confirm
// for cards; single or multi). Split out of AtlasBoard.tsx along its
// own effect seam (the 500-line convention). Never fires from
// editable elements, and stands down entirely while a modal dialog
// owns the screen (shared/modalGate.ts, the goal-0183 gesture-leak
// class): a Delete reaching the COVERED board would destroy a
// selection the user can't even see behind the dialog.
export function useAtlasDeleteKey({ cards, notes, objects, selectedIDsRef, onDeleteSelection }: {
cards: Card[]
notes: Note[]
objects: BoardObject[]
selectedIDsRef: RefObject<string[]>
onDeleteSelection: (cardIDs: string[], noteIDs: string[], objectIDs?: string[]) => void
}) {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Delete' && e.key !== 'Backspace') return
if (modalSurfaceOpen()) return
const el = document.activeElement
if (el instanceof HTMLElement && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
const sel = selectedIDsRef.current
if (sel.length === 0) return
e.preventDefault()
const cardIDs = sel.filter((id) => cards.some((c) => c.ID === id))
const noteIDs = sel.filter((id) => notes.some((n) => n.ID === id))
const objectIDs = sel.filter((id) => objects.some((o) => o.ID === id))
if (cardIDs.length + noteIDs.length + objectIDs.length > 0) onDeleteSelection(cardIDs, noteIDs, objectIDs)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [cards, notes, objects, onDeleteSelection, selectedIDsRef])
}
33 changes: 22 additions & 11 deletions frontend/src/atlas/useAtlasNativeFileDrop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Events } from '@wailsio/runtime'
import { AtlasService } from '../shared/bindings'
Expand Down Expand Up @@ -78,15 +78,19 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
stateRef.current = { parentID, topLevelBoxes, screenToFlowPosition, requestFolderImport, setPulsedID, reduceMotion, diagramObjectCreate, imageObjectCreate, sheetObjectCreate }
}, [parentID, topLevelBoxes, screenToFlowPosition, requestFolderImport, setPulsedID, reduceMotion, diagramObjectCreate, imageObjectCreate, sheetObjectCreate])

useEffect(() => {
return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
const { topLevelBoxes: boxes, screenToFlowPosition: toFlow, parentID: currentParentID, requestFolderImport: request, setPulsedID: pulse, reduceMotion: reduced, diagramObjectCreate: diagramCreate, imageObjectCreate: imageCreate, sheetObjectCreate: sheetCreate } = stateRef.current
const point = toFlow({ x: payload.x ?? 0, y: payload.y ?? 0 })
const targetParentID = frameContainingPoint(boxes, point) ?? currentParentID
// The drop door's LANDING half, split from the OS drop gesture so the
// board's paste door can land a pasted file PATH through the exact
// same pipeline (routing, plugin claims, extension enablement, folder
// import, the card fallback's pulse/duplicate notice). Returns the
// un-caught promise: each gesture owns its own failure answer -- the
// OS drop shows dropError below, a pasted path falls back to the
// ordinary text-paste flow (useAtlasPaste.ts).
const landFiles = useCallback((filenames: string[], screenPoint: { x: number; y: number }) => {
const { topLevelBoxes: boxes, screenToFlowPosition: toFlow, parentID: currentParentID, requestFolderImport: request, setPulsedID: pulse, reduceMotion: reduced, diagramObjectCreate: diagramCreate, imageObjectCreate: imageCreate, sheetObjectCreate: sheetCreate } = stateRef.current
const point = toFlow(screenPoint)
const targetParentID = frameContainingPoint(boxes, point) ?? currentParentID

AtlasService.ResolveFileDropRoute(payload.filenames)
return AtlasService.ResolveFileDropRoute(filenames)
.then((route) => {
if (route.Kind === 'import') {
request(route.Path, targetParentID)
Expand Down Expand Up @@ -128,9 +132,16 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
}))
}
})
}, [t])

useEffect(() => {
return Events.On(FILE_DROP_EVENT_NAME, (evt) => {
const payload = evt.data as { filenames?: string[]; x?: number; y?: number; context?: string } | undefined
if (!payload || payload.context !== FILE_DROP_CONTEXT_BOARD || !payload.filenames?.length) return
void landFiles(payload.filenames, { x: payload.x ?? 0, y: payload.y ?? 0 })
.catch(() => setDropError(t('capture.dropError')))
})
}, [t])
}, [t, landFiles])

useEffect(() => {
if (!dropError) return
Expand All @@ -144,5 +155,5 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo
return () => window.clearTimeout(timer)
}, [dropDuplicateNotice])

return { dropError, dropDuplicateNotice }
return { dropError, dropDuplicateNotice, landFiles }
}
Loading
Loading