fix(client): persist workspace drag order
This commit is contained in:
@@ -173,10 +173,19 @@ export class WorkspaceManager {
|
|||||||
const frameGeneration = this.orderFrameGeneration
|
const frameGeneration = this.orderFrameGeneration
|
||||||
const previousOrder = this.itemViews().map(workspace => workspace.workspaceId)
|
const previousOrder = this.itemViews().map(workspace => workspace.workspaceId)
|
||||||
this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId))
|
this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId))
|
||||||
const { result } = await this.api.workspace.insertBefore({
|
let result: RpcResult<{ workspaceIds: WorkspaceId[] }>
|
||||||
workspaceId,
|
try {
|
||||||
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
|
;({ result } = await this.api.workspace.insertBefore({
|
||||||
})
|
workspaceId,
|
||||||
|
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
if (requestGeneration === this.orderRequestGeneration
|
||||||
|
&& frameGeneration === this.orderFrameGeneration) {
|
||||||
|
this.installOrder(previousOrder)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
if (result.ok && requestGeneration === this.orderRequestGeneration
|
if (result.ok && requestGeneration === this.orderRequestGeneration
|
||||||
&& frameGeneration === this.orderFrameGeneration) {
|
&& frameGeneration === this.orderFrameGeneration) {
|
||||||
this.installOrder(result.value.workspaceIds)
|
this.installOrder(result.value.workspaceIds)
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ describe('WorkspaceManager', () => {
|
|||||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
|
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
|
||||||
await expect(rejected).resolves.toMatchObject({ ok: false })
|
await expect(rejected).resolves.toMatchObject({ ok: false })
|
||||||
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||||
|
|
||||||
|
api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down'))
|
||||||
|
const disconnected = manager.insertBefore(wid('three'), wid('one'))
|
||||||
|
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
|
||||||
|
await expect(disconnected).rejects.toThrow('transport down')
|
||||||
|
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
|
||||||
|
|||||||
@@ -200,9 +200,10 @@ function SessionTree({
|
|||||||
const [drag, setDrag] = useState<DragState | null>(null)
|
const [drag, setDrag] = useState<DragState | null>(null)
|
||||||
const sessionDropCommitted = useRef(false)
|
const sessionDropCommitted = useRef(false)
|
||||||
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
|
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
|
||||||
const sessionDragging = drag !== null
|
const workspaceDropCommitted = useRef(false)
|
||||||
|
const nativeDragActive = drag !== null || workspaceDrag !== null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionDragging) return
|
if (!nativeDragActive) return
|
||||||
// Row hover still owns the insertion marker. Accept the native drag at
|
// Row hover still owns the insertion marker. Accept the native drag at
|
||||||
// document level so releasing outside the list is not rendered as a
|
// document level so releasing outside the list is not rendered as a
|
||||||
// rejected drop before dragend commits that last marker.
|
// rejected drop before dragend commits that last marker.
|
||||||
@@ -217,7 +218,7 @@ function SessionTree({
|
|||||||
document.removeEventListener('dragover', acceptDrag)
|
document.removeEventListener('dragover', acceptDrag)
|
||||||
document.removeEventListener('drop', acceptDrop)
|
document.removeEventListener('drop', acceptDrop)
|
||||||
}
|
}
|
||||||
}, [sessionDragging])
|
}, [nativeDragActive])
|
||||||
const currentGroup = current === undefined
|
const currentGroup = current === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||||
@@ -310,6 +311,26 @@ function SessionTree({
|
|||||||
console.warn('session reorder rejected:', reason)
|
console.warn('session reorder rejected:', reason)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const commitWorkspaceDrag = (
|
||||||
|
activeDrag: WorkspaceDragState,
|
||||||
|
over: NonNullable<WorkspaceDragState['over']>,
|
||||||
|
): void => {
|
||||||
|
if (workspaceDropCommitted.current) return
|
||||||
|
workspaceDropCommitted.current = true
|
||||||
|
setWorkspaceDrag(null)
|
||||||
|
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === over.id)
|
||||||
|
if (rowIndex === -1) return
|
||||||
|
const anchor = over.half === 'before' ? over.id : workspaces[rowIndex + 1]?.workspaceId
|
||||||
|
if (anchor === activeDrag.workspaceId) return
|
||||||
|
const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
|
||||||
|
const anchorIndex = anchor === undefined
|
||||||
|
? workspaces.length
|
||||||
|
: workspaces.findIndex(workspace => workspace.workspaceId === anchor)
|
||||||
|
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
||||||
|
insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => {
|
||||||
|
console.warn('workspace reorder rejected:', reason)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(css.treeBody, css.wide)}>
|
<div className={clsx(css.treeBody, css.wide)}>
|
||||||
@@ -323,8 +344,18 @@ function SessionTree({
|
|||||||
? workspaceDrag.over.half
|
? workspaceDrag.over.half
|
||||||
: null
|
: null
|
||||||
const workspaceDragProps = workspaceId === undefined ? undefined : {
|
const workspaceDragProps = workspaceId === undefined ? undefined : {
|
||||||
start: () => { setWorkspaceDrag({ workspaceId, over: null }) },
|
start: () => {
|
||||||
end: () => { setWorkspaceDrag(null) },
|
workspaceDropCommitted.current = false
|
||||||
|
setWorkspaceDrag({ workspaceId, over: null })
|
||||||
|
},
|
||||||
|
end: () => {
|
||||||
|
if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) {
|
||||||
|
commitWorkspaceDrag(workspaceDrag, workspaceDrag.over)
|
||||||
|
} else {
|
||||||
|
setWorkspaceDrag(null)
|
||||||
|
}
|
||||||
|
workspaceDropCommitted.current = false
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const hoverWorkspace = workspaceId === undefined
|
const hoverWorkspace = workspaceId === undefined
|
||||||
? undefined
|
? undefined
|
||||||
@@ -337,18 +368,7 @@ function SessionTree({
|
|||||||
? undefined
|
? undefined
|
||||||
: (half: 'before' | 'after') => {
|
: (half: 'before' | 'after') => {
|
||||||
if (workspaceDrag === null) return
|
if (workspaceDrag === null) return
|
||||||
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half })
|
||||||
const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId
|
|
||||||
setWorkspaceDrag(null)
|
|
||||||
if (anchor === workspaceDrag.workspaceId) return
|
|
||||||
const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceDrag.workspaceId)
|
|
||||||
const anchorIndex = anchor === undefined
|
|
||||||
? workspaces.length
|
|
||||||
: workspaces.findIndex(workspace => workspace.workspaceId === anchor)
|
|
||||||
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
||||||
insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => {
|
|
||||||
console.warn('workspace reorder rejected:', reason)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
// Group section: header row + expanded top-level session rows. The
|
// Group section: header row + expanded top-level session rows. The
|
||||||
|
|||||||
@@ -629,6 +629,34 @@ describe('WorkspaceBrowser', () => {
|
|||||||
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a document-level drop and commits the last Workspace marker on drag end', () => {
|
||||||
|
const insertWorkspaceBefore = vi.fn(async () => {})
|
||||||
|
mount({
|
||||||
|
useWorkspaces: hook(workspaceState([
|
||||||
|
workspace('alpha', []),
|
||||||
|
workspace('beta', []),
|
||||||
|
workspace('tail', []),
|
||||||
|
])),
|
||||||
|
insertWorkspaceBefore,
|
||||||
|
})
|
||||||
|
const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement
|
||||||
|
let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement
|
||||||
|
while (target.parentElement?.getAttribute('role') !== 'tree') {
|
||||||
|
target = target.parentElement as HTMLElement
|
||||||
|
}
|
||||||
|
target.getBoundingClientRect = () => ({
|
||||||
|
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
fireEvent.dragStart(source, { dataTransfer: dragData() })
|
||||||
|
fireDrag(target, 'dragOver', 105)
|
||||||
|
const outsideDrop = createEvent.drop(document.body)
|
||||||
|
Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() })
|
||||||
|
fireEvent(document.body, outsideDrop)
|
||||||
|
expect(outsideDrop.defaultPrevented).toBe(true)
|
||||||
|
fireEvent.dragEnd(source)
|
||||||
|
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
||||||
|
})
|
||||||
|
|
||||||
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
|
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
|
||||||
const insertSessionBefore = vi.fn(async () => {})
|
const insertSessionBefore = vi.fn(async () => {})
|
||||||
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
||||||
|
|||||||
Reference in New Issue
Block a user