|
|
@@ -25,7 +25,7 @@ afterEach(cleanup)
|
|
|
const scrollIntoView = vi.fn()
|
|
|
beforeEach(() => {
|
|
|
localStorage.clear()
|
|
|
- createWorkspaceViewStore().create().actions.setOrderBy('manual')
|
|
|
+ createWorkspaceViewStore().create().actions.setOrderBy('manual', {})
|
|
|
Element.prototype.scrollIntoView = scrollIntoView
|
|
|
scrollIntoView.mockClear()
|
|
|
})
|
|
|
@@ -94,7 +94,6 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
|
|
deleteWorkspace: vi.fn(async () => {}),
|
|
|
archiveSession: vi.fn(async () => {}),
|
|
|
insertWorkspaceBefore: vi.fn(async () => {}),
|
|
|
- insertSessionBefore: vi.fn(async () => {}),
|
|
|
createWorkspace: vi.fn(async () => workspace('created', [])),
|
|
|
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
|
|
useHostInfo: selector => selector({ home: undefined, isLoopback: true }),
|
|
|
@@ -113,6 +112,108 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
|
|
|
}
|
|
|
|
|
|
describe('WorkspaceBrowser', () => {
|
|
|
+ it.each(['workspace', 'flat', 'ungrouped'] as const)('keeps %s recency independent of arrival order and saved manual positions', (mode) => {
|
|
|
+ localStorage.clear()
|
|
|
+ const preferences = createWorkspaceViewStore().create()
|
|
|
+ preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
|
|
|
+ const account = mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
|
|
|
+ preferences.actions.setSessionOrder(account, ['older', 'newer'], {})
|
|
|
+ preferences.actions.setSessionOrder(UNGROUPED_KEY, ['older', 'newer'], {})
|
|
|
+ preferences.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['older', 'newer'], {})
|
|
|
+ preferences.actions.setGroupExpanded(account, true)
|
|
|
+ preferences.actions.setGroupExpanded(UNGROUPED_KEY, true)
|
|
|
+ localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({ ...preferences.getSnapshot(), orderBy: 'updated' }))
|
|
|
+ const b = mount({
|
|
|
+ useSessions: hook(sessionState([summary('newer', 100)])),
|
|
|
+ useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace(account, ['older', 'newer'])])),
|
|
|
+ })
|
|
|
+ const names = () => screen.getAllByRole('treeitem')
|
|
|
+ .filter(row => row.getAttribute('aria-expanded') === null)
|
|
|
+ .map(row => row.textContent?.includes('newer') ? 'newer' : 'older')
|
|
|
+ expect(names()).toEqual(['newer'])
|
|
|
+ rerender(b, { useSessions: hook(sessionState([summary('older', 20), summary('newer', 100)])) })
|
|
|
+ expect(names()).toEqual(['newer', 'older'])
|
|
|
+ rerender(b, { useSessions: hook(sessionState([summary('older', 80), summary('newer', 100)])) })
|
|
|
+ expect(names()).toEqual(['newer', 'older'])
|
|
|
+ rerender(b, { useSessions: hook(sessionState([summary('older', 120), summary('newer', 100)])) })
|
|
|
+ expect(names()).toEqual(['older', 'newer'])
|
|
|
+ rerender(b, { useSessions: hook(sessionState([summary('older', 80), summary('newer', 100)])) })
|
|
|
+ expect(names()).toEqual(['newer', 'older'])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['older', 'newer'])
|
|
|
+ b.view.unmount()
|
|
|
+ const restored = mount({
|
|
|
+ useSessions: hook(sessionState([summary('older', 80), summary('newer', 100)])),
|
|
|
+ useWorkspaces: b.props.useWorkspaces,
|
|
|
+ })
|
|
|
+ expect(names()).toEqual(['newer', 'older'])
|
|
|
+ act(() => { restored.store.actions.setOrderBy('manual', {}) })
|
|
|
+ expect(names()).toEqual(['newer', 'older'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each(['workspace', 'flat', 'ungrouped'] as const)('starts Manual from the current %s order and forgets discarded layouts', (mode) => {
|
|
|
+ localStorage.clear()
|
|
|
+ const preferences = createWorkspaceViewStore().create()
|
|
|
+ preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
|
|
|
+ const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
|
|
|
+ preferences.actions.setGroupExpanded(account, true)
|
|
|
+ const b = mount({
|
|
|
+ useSessions: hook(sessionState([summary('a', 30), summary('b', 20), summary('c', 10)])),
|
|
|
+ useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['c', 'a', 'b'])])),
|
|
|
+ })
|
|
|
+ const names = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
|
|
|
+ .map(row => row.querySelector('[class*="title"]')?.textContent)
|
|
|
+ const pick = (name: string) => {
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name }))
|
|
|
+ }
|
|
|
+ expect(names()).toEqual(['a', 'b', 'c'])
|
|
|
+ pick('手动排序')
|
|
|
+ expect(names()).toEqual(['a', 'b', 'c'])
|
|
|
+ const source = screen.getByText('c').closest('[role="treeitem"]') as HTMLElement
|
|
|
+ const target = screen.getByText('a').closest('[role="treeitem"]') 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, 'drop', 102)
|
|
|
+ expect(names()).toEqual(['c', 'a', 'b'])
|
|
|
+ rerender(b, { useSessions: hook(sessionState([summary('a', 30), summary('b', 40), summary('c', 10)])) })
|
|
|
+ expect(names()).toEqual(['c', 'a', 'b'])
|
|
|
+ b.view.unmount()
|
|
|
+ const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
|
|
|
+ expect(names()).toEqual(['c', 'a', 'b'])
|
|
|
+ expect(restored.store.getSnapshot().orderBy).toBe('manual')
|
|
|
+ pick('最近更新')
|
|
|
+ expect(names()).toEqual(['b', 'a', 'c'])
|
|
|
+ pick('手动排序')
|
|
|
+ expect(names()).toEqual(['b', 'a', 'c'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each(['workspace', 'flat'] as const)('keeps the provisional blank first and time ties stable in %s recency', (groupBy) => {
|
|
|
+ localStorage.clear()
|
|
|
+ const preferences = createWorkspaceViewStore().create()
|
|
|
+ preferences.actions.setGroupBy(groupBy)
|
|
|
+ preferences.actions.setGroupExpanded('alpha', true)
|
|
|
+ const b = mount({
|
|
|
+ useSessions: hook(sessionState([
|
|
|
+ summary('tie-b', 100), summary('blank', 1, { blank: true }), summary('tie-a', 100),
|
|
|
+ ], { current: sid('blank') })),
|
|
|
+ useWorkspaces: hook(workspaceState([workspace('alpha', ['missing', 'tie-b', 'blank', 'tie-a'])])),
|
|
|
+ })
|
|
|
+ const rows = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
|
|
|
+ expect(rows().map(row => row.textContent)).toEqual([
|
|
|
+ expect.stringContaining('新会话'), expect.stringContaining('tie-a'), expect.stringContaining('tie-b'),
|
|
|
+ ])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
|
|
+ rerender(b, { useSessions: hook(sessionState([
|
|
|
+ summary('tie-b', 100), summary('blank', 1), summary('tie-a', 100),
|
|
|
+ ], { current: sid('blank') })) })
|
|
|
+ expect(rows().map(row => row.textContent)).toEqual([
|
|
|
+ expect.stringContaining('tie-a'), expect.stringContaining('tie-b'), expect.stringContaining('blank'),
|
|
|
+ ])
|
|
|
+ expect(b.store.getSnapshot().orderBy).toBe('updated')
|
|
|
+ })
|
|
|
+
|
|
|
it('moves focus into Workspace controls without selecting a Session while a main panel is active', () => {
|
|
|
const panelInfo = { activePanelId: 'panel-a' as MainPanelId }
|
|
|
const b = mount({
|
|
|
@@ -165,15 +266,111 @@ describe('WorkspaceBrowser', () => {
|
|
|
const b = mount({ useWorkspaces: hook(pending) })
|
|
|
act(() => {
|
|
|
b.store.actions.setGroupExpanded('deleted', true)
|
|
|
- b.store.actions.syncSessionOrderAccount('deleted', ['session'], { session: 1 })
|
|
|
+ b.store.actions.setSessionOrder('deleted', ['session'], {})
|
|
|
})
|
|
|
expect(b.store.getSnapshot().groupExpansion).toEqual({ deleted: true })
|
|
|
|
|
|
rerender(b, { useWorkspaces: hook(workspaceState([])) })
|
|
|
await waitFor(() => {
|
|
|
expect(b.store.getSnapshot().groupExpansion).toEqual({})
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({ [UNGROUPED_KEY]: [] })
|
|
|
- expect(b.store.getSnapshot().sessionUpdatedAtByAccount).toEqual({ [UNGROUPED_KEY]: {} })
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({
|
|
|
+ [UNGROUPED_KEY]: [],
|
|
|
+ [FLAT_SESSION_ORDER_KEY]: [],
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it.each(['workspace', 'flat', 'ungrouped'] as const)('retains the new Session position through a %s Workspace reconnect', (mode) => {
|
|
|
+ const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
|
|
|
+ const old = summary('old', 10)
|
|
|
+ const absent = summary('absent', 5)
|
|
|
+ const blank = summary('blank', 1, { blank: true })
|
|
|
+ const groups = (ids: string[]) => workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ids)])
|
|
|
+ const preferences = createWorkspaceViewStore().create()
|
|
|
+ preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
|
|
|
+ preferences.actions.setGroupExpanded(account, true)
|
|
|
+ preferences.actions.setSessionOrder(account, ['old', 'absent'], {})
|
|
|
+ const b = mount({
|
|
|
+ useSessions: hook(sessionState([old, absent])),
|
|
|
+ useWorkspaces: hook(groups(['old', 'absent'])),
|
|
|
+ })
|
|
|
+ const names = () => screen.getAllByRole('treeitem')
|
|
|
+ .filter(row => row.getAttribute('aria-expanded') === null)
|
|
|
+ .map(row => row.textContent)
|
|
|
+ rerender(b, {
|
|
|
+ useSessions: hook(sessionState([old, blank], { current: blank.id })),
|
|
|
+ useWorkspaces: hook({ ...groups(['old', 'blank']), state: 'loading' }),
|
|
|
+ })
|
|
|
+ expect(names()).toEqual([expect.stringContaining('新会话'), expect.stringContaining('old')])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
|
|
|
+
|
|
|
+ rerender(b, {
|
|
|
+ useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
|
|
|
+ })
|
|
|
+ expect(names()).toEqual([expect.stringContaining('blank'), expect.stringContaining('old')])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
|
|
|
+
|
|
|
+ rerender(b, {
|
|
|
+ useSessions: hook(sessionState([old, absent, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
|
|
|
+ useWorkspaces: hook(groups(['old', 'absent', 'blank'])),
|
|
|
+ })
|
|
|
+ expect(names()).toEqual([
|
|
|
+ expect.stringContaining('blank'), expect.stringContaining('old'), expect.stringContaining('absent'),
|
|
|
+ ])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
|
|
|
+ rerender(b, {
|
|
|
+ useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
|
|
|
+ useWorkspaces: hook(groups(['old', 'blank'])),
|
|
|
+ })
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reconciles a late blank and its first prompt while the sidebar is collapsed', async () => {
|
|
|
+ const old = summary('old', 10)
|
|
|
+ const blank = summary('blank', 1, { blank: true })
|
|
|
+ const b = mount({
|
|
|
+ wide: false,
|
|
|
+ useSessions: hook(sessionState([old])),
|
|
|
+ useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank'])])),
|
|
|
+ })
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old'])
|
|
|
+ })
|
|
|
+
|
|
|
+ rerender(b, {
|
|
|
+ useSessions: hook(sessionState([old, blank], { current: blank.id })),
|
|
|
+ })
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old'])
|
|
|
+ })
|
|
|
+
|
|
|
+ rerender(b, {
|
|
|
+ useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
|
|
|
+ })
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old'])
|
|
|
+ })
|
|
|
+
|
|
|
+ rerender(b, { wide: true })
|
|
|
+ expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
|
|
|
+ expect.stringContaining('blank'),
|
|
|
+ expect.stringContaining('old'),
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drops the obsolete timestamp ledger from persisted viewing state', async () => {
|
|
|
+ localStorage.clear()
|
|
|
+ localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({
|
|
|
+ groupBy: 'workspace',
|
|
|
+ orderBy: 'manual',
|
|
|
+ groupExpansion: {},
|
|
|
+ sessionOrderByAccount: {},
|
|
|
+ sessionUpdatedAtByAccount: { alpha: { old: 10 } },
|
|
|
+ }))
|
|
|
+ mount()
|
|
|
+ await waitFor(() => {
|
|
|
+ const persisted = JSON.parse(localStorage.getItem('dsh.workspace.view.v5') ?? '{}') as Record<string, unknown>
|
|
|
+ expect(persisted).not.toHaveProperty('sessionUpdatedAtByAccount')
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -219,7 +416,6 @@ describe('WorkspaceBrowser', () => {
|
|
|
})
|
|
|
|
|
|
it('persists flat-list drag order locally and applies Last updated within that account', async () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
|
|
const workspaces = workspaceState([
|
|
|
workspace('alpha', ['one']),
|
|
|
@@ -228,7 +424,6 @@ describe('WorkspaceBrowser', () => {
|
|
|
const b = mount({
|
|
|
useSessions: hook(sessions),
|
|
|
useWorkspaces: hook(workspaces),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
|
|
@@ -247,13 +442,14 @@ describe('WorkspaceBrowser', () => {
|
|
|
fireDrag(three, 'drop', 180)
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
|
|
|
.toEqual(['two', 'three', 'one'])
|
|
|
- expect(insertSessionBefore).not.toHaveBeenCalled()
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
|
|
await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
|
|
|
- .toEqual(['one', 'two', 'three'])
|
|
|
+ expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
|
|
|
+ expect.stringContaining('one'), expect.stringContaining('two'), expect.stringContaining('three'),
|
|
|
+ ])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
|
|
})
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
@@ -336,55 +532,38 @@ describe('WorkspaceBrowser', () => {
|
|
|
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
|
|
|
})
|
|
|
|
|
|
- it('anchors collapsed drags before hidden rows so the source stays visible', async () => {
|
|
|
+ it('pins the blank while collapsed drags keep an ordinary source visible', async () => {
|
|
|
const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index))
|
|
|
const blank = summary('blank', 7, { blank: true })
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
const b = mount({
|
|
|
useSessions: hook(sessionState([blank, ...ordinary], { current: blank.id })),
|
|
|
useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
await waitFor(() => {
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount.alpha)
|
|
|
.toEqual(['blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6'])
|
|
|
})
|
|
|
|
|
|
- fireEvent.click(screen.getByRole('button', { name: '展开其余 1 个会话' }))
|
|
|
const blankRow = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
|
|
|
- const session6 = screen.getByText('session-6').closest('[role="treeitem"]') as HTMLElement
|
|
|
- session6.getBoundingClientRect = () => ({
|
|
|
- top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34,
|
|
|
- x: 0, y: 200, toJSON: () => ({}),
|
|
|
- })
|
|
|
+ expect(blankRow.draggable).toBe(false)
|
|
|
fireEvent.dragStart(blankRow, { dataTransfer: dragData() })
|
|
|
- fireDrag(session6, 'drop', 230)
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount.alpha)
|
|
|
- .toEqual(['session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6', 'blank'])
|
|
|
+ .toEqual(['blank', 'session-1', 'session-2', 'session-3', 'session-4', 'session-5', 'session-6'])
|
|
|
|
|
|
- insertSessionBefore.mockClear()
|
|
|
- fireEvent.click(screen.getByRole('button', { name: '收起' }))
|
|
|
- const collapsedBlank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
|
|
|
- collapsedBlank.getBoundingClientRect = () => ({
|
|
|
+ blankRow.getBoundingClientRect = () => ({
|
|
|
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34,
|
|
|
x: 0, y: 200, toJSON: () => ({}),
|
|
|
})
|
|
|
const session5 = screen.getByText('session-5').closest('[role="treeitem"]') as HTMLElement
|
|
|
fireEvent.dragStart(session5, { dataTransfer: dragData() })
|
|
|
- fireDrag(collapsedBlank, 'drop', 205)
|
|
|
- expect(insertSessionBefore).not.toHaveBeenCalled()
|
|
|
-
|
|
|
- const session4 = screen.getByText('session-4').closest('[role="treeitem"]') as HTMLElement
|
|
|
- fireEvent.dragStart(session4, { dataTransfer: dragData() })
|
|
|
- fireDrag(collapsedBlank, 'drop', 205)
|
|
|
+ fireDrag(blankRow, 'drop', 205)
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount.alpha)
|
|
|
- .toEqual(['session-1', 'session-2', 'session-3', 'session-5', 'session-4', 'session-6', 'blank'])
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('session-4'), sid('session-6'))
|
|
|
- expect(screen.getByText('session-4')).toBeTruthy()
|
|
|
+ .toEqual(['blank', 'session-5', 'session-1', 'session-2', 'session-3', 'session-4', 'session-6'])
|
|
|
+ expect(screen.getByText('session-5')).toBeTruthy()
|
|
|
expect(screen.queryByText('session-6')).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('shares one editable order across modes and promotes only while Last updated is active', async () => {
|
|
|
+ it('discards manual positions on recency selection and switches to Manual on drag', async () => {
|
|
|
const initial = sessionState([summary('one', 3), summary('two', 2)])
|
|
|
const b = mount({
|
|
|
useSessions: hook(initial),
|
|
|
@@ -404,37 +583,33 @@ describe('WorkspaceBrowser', () => {
|
|
|
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
|
|
})
|
|
|
fireEvent.dragStart(one, { dataTransfer: dragData() })
|
|
|
+ fireDrag(one, 'drop', 180)
|
|
|
+ expect(b.store.getSnapshot().orderBy).toBe('updated')
|
|
|
+ fireEvent.dragStart(one, { dataTransfer: dragData() })
|
|
|
fireDrag(two, 'drop', 180)
|
|
|
+ expect(b.store.getSnapshot().orderBy).toBe('manual')
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
|
|
|
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
|
|
|
|
|
- // User activity updates the timestamp baseline in Manual mode without
|
|
|
- // changing the shared visual order.
|
|
|
const updated = sessionState([summary('one', 4), summary('two', 2)])
|
|
|
rerender(b, { useSessions: hook(updated) })
|
|
|
- await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionUpdatedAtByAccount.alpha).toEqual({ one: 4, two: 2 })
|
|
|
- })
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
|
|
|
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
|
|
|
|
|
- // Entering Last updated performs one complete recency sort.
|
|
|
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
|
|
await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
|
|
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
|
|
|
})
|
|
|
|
|
|
- // A later user activity timestamp promotes that Session once while the
|
|
|
- // mode remains active.
|
|
|
const promoted = sessionState([summary('one', 4), summary('two', 5)])
|
|
|
rerender(b, { useSessions: hook(promoted) })
|
|
|
await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
|
|
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
|
|
})
|
|
|
|
|
|
@@ -443,7 +618,7 @@ describe('WorkspaceBrowser', () => {
|
|
|
useSessions: hook(promoted),
|
|
|
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
|
|
|
})
|
|
|
- expect(restored.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
|
|
|
+ expect(restored.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
|
|
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
|
|
|
})
|
|
|
|
|
|
@@ -579,70 +754,70 @@ describe('WorkspaceBrowser', () => {
|
|
|
expect(screen.queryByText('新会话')).toBeNull()
|
|
|
})
|
|
|
|
|
|
- it('promotes the blank selected by New Session in its grouped and flat orders', async () => {
|
|
|
- const items = [
|
|
|
- summary('old', 100),
|
|
|
- summary('blank', 150, { blank: true }),
|
|
|
- summary('mid', 200),
|
|
|
- ]
|
|
|
- const startSession = vi.fn()
|
|
|
- const b = mount({
|
|
|
- useSessions: hook(sessionState(items)),
|
|
|
- useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
|
|
|
- startSession,
|
|
|
- })
|
|
|
- await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'blank', 'mid'])
|
|
|
- })
|
|
|
- startSession.mockImplementation(() => {
|
|
|
- rerender(b, { useSessions: hook(sessionState(items, { current: sid('blank') })) })
|
|
|
- })
|
|
|
- fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
|
|
|
- expect(startSession).toHaveBeenCalledWith(wid('alpha'))
|
|
|
- await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank'])
|
|
|
- })
|
|
|
- b.store.actions.setGroupBy('flat')
|
|
|
- await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank', 'mid', 'old'])
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- it('does not repeat blank promotion after a manual drag or the first prompt', async () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
+ it.each(['workspace', 'flat', 'ungrouped'] as const)('keeps a new blank first across %s mode switches and enables drag after the first prompt', (mode) => {
|
|
|
+ localStorage.clear()
|
|
|
+ const preferences = createWorkspaceViewStore().create()
|
|
|
+ preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
|
|
|
+ const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
|
|
|
+ preferences.actions.setGroupExpanded(account, true)
|
|
|
+ const groups = (ids: string[]) => hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ids)]))
|
|
|
const b = mount({
|
|
|
+ useSessions: hook(sessionState([summary('old', 100), summary('mid', 200)])),
|
|
|
+ useWorkspaces: groups(['old', 'mid']),
|
|
|
+ })
|
|
|
+ const names = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
|
|
|
+ .map(row => row.querySelector('[class*="title"]')?.textContent)
|
|
|
+ const pick = (name: string) => {
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
+ fireEvent.click(screen.getByRole('menuitem', { name }))
|
|
|
+ }
|
|
|
+ pick('手动排序')
|
|
|
+ pick('最近更新')
|
|
|
+ rerender(b, {
|
|
|
useSessions: hook(sessionState([
|
|
|
- summary('old', 100),
|
|
|
- summary('blank', 150, { blank: true }),
|
|
|
- summary('mid', 200),
|
|
|
+ summary('old', 100), summary('mid', 200), summary('blank', 1, { blank: true }),
|
|
|
], { current: sid('blank') })),
|
|
|
- useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
|
|
|
- insertSessionBefore,
|
|
|
- })
|
|
|
- await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
|
|
|
+ useWorkspaces: groups(['old', 'mid', 'blank']),
|
|
|
})
|
|
|
+ expect(names()).toEqual(['新会话', 'mid', 'old'])
|
|
|
+ expect(b.store.getSnapshot().orderBy).toBe('updated')
|
|
|
+ pick('手动排序')
|
|
|
+ expect(names()).toEqual(['新会话', 'mid', 'old'])
|
|
|
+ pick('最近更新')
|
|
|
const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
|
|
|
- const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement
|
|
|
- mid.getBoundingClientRect = () => ({
|
|
|
+ expect(blank.draggable).toBe(false)
|
|
|
+ const old = screen.getByText('old').closest('[role="treeitem"]') as HTMLElement
|
|
|
+ old.getBoundingClientRect = () => ({
|
|
|
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
|
|
})
|
|
|
fireEvent.dragStart(blank, { dataTransfer: dragData() })
|
|
|
- fireDrag(mid, 'drop', 180)
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('blank'), undefined)
|
|
|
-
|
|
|
- rerender(b, {
|
|
|
+ fireDrag(old, 'drop', 180)
|
|
|
+ expect(names()).toEqual(['新会话', 'mid', 'old'])
|
|
|
+ b.view.unmount()
|
|
|
+ const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
|
|
|
+ expect(names()).toEqual(['新会话', 'mid', 'old'])
|
|
|
+ rerender(restored, { useSessions: hook(sessionState([
|
|
|
+ summary('old', 100), summary('mid', 200), summary('blank', 300),
|
|
|
+ ], { current: sid('blank') })) })
|
|
|
+ expect(names()).toEqual(['blank', 'mid', 'old'])
|
|
|
+ const ordinaryBlank = screen.getByText('blank').closest('[role="treeitem"]') as HTMLElement
|
|
|
+ expect(ordinaryBlank.draggable).toBe(true)
|
|
|
+ const restoredOld = screen.getByText('old').closest('[role="treeitem"]') as HTMLElement
|
|
|
+ restoredOld.getBoundingClientRect = () => old.getBoundingClientRect()
|
|
|
+ fireEvent.dragStart(ordinaryBlank, { dataTransfer: dragData() })
|
|
|
+ fireDrag(restoredOld, 'drop', 180)
|
|
|
+ expect(names()).toEqual(['mid', 'old', 'blank'])
|
|
|
+ rerender(restored, {
|
|
|
useSessions: hook(sessionState([
|
|
|
- summary('old', 100),
|
|
|
- summary('blank', 150),
|
|
|
- summary('mid', 200),
|
|
|
- ], { current: sid('blank') })),
|
|
|
- })
|
|
|
- await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
|
|
|
- })
|
|
|
+ summary('old', 100), summary('mid', 200), summary('blank', 300), summary('new-blank', 1, { blank: true }),
|
|
|
+ ], { current: sid('new-blank') })),
|
|
|
+ useWorkspaces: groups(['old', 'mid', 'blank', 'new-blank']),
|
|
|
+ })
|
|
|
+ expect(names()).toEqual(['新会话', 'mid', 'old', 'blank'])
|
|
|
+ pick('最近更新')
|
|
|
+ expect(names()).toEqual(['新会话', 'blank', 'mid', 'old'])
|
|
|
+ pick('手动排序')
|
|
|
+ expect(names()).toEqual(['新会话', 'blank', 'mid', 'old'])
|
|
|
})
|
|
|
|
|
|
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
|
|
|
@@ -1168,13 +1343,11 @@ describe('WorkspaceBrowser', () => {
|
|
|
expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta'))
|
|
|
})
|
|
|
|
|
|
- it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
+ it('persists a grouped drag locally and skips no-op drops', () => {
|
|
|
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
|
|
- mount({
|
|
|
+ const b = mount({
|
|
|
useSessions: hook(sessions),
|
|
|
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
fireEvent.click(screen.getByText('alpha'))
|
|
|
const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
|
|
|
@@ -1187,7 +1360,7 @@ describe('WorkspaceBrowser', () => {
|
|
|
// Drop on the top half of "three": insert one before three.
|
|
|
fireDrag(three, 'dragOver', 205)
|
|
|
fireDrag(three, 'drop', 205)
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
|
|
|
|
|
|
// Dropping right back onto its own position is a no-op — top half
|
|
|
// (anchor = itself) and bottom half (anchor = the next root) alike.
|
|
|
@@ -1197,19 +1370,17 @@ describe('WorkspaceBrowser', () => {
|
|
|
})
|
|
|
fireDrag(one, 'dragOver', 105)
|
|
|
fireDrag(one, 'drop', 105)
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
|
|
|
fireEvent.dragStart(one, { dataTransfer })
|
|
|
fireDrag(one, 'drop', 130)
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one', 'three'])
|
|
|
})
|
|
|
|
|
|
- it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
+ it('persists Ungrouped drag order in both modes', async () => {
|
|
|
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
|
|
|
const b = mount({
|
|
|
useSessions: hook(sessions),
|
|
|
useWorkspaces: hook(workspaceState([])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
fireEvent.click(screen.getByText('未分组'))
|
|
|
|
|
|
@@ -1227,22 +1398,22 @@ describe('WorkspaceBrowser', () => {
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
|
|
|
dragAfter('two', 'one')
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
|
|
|
- expect(insertSessionBefore).not.toHaveBeenCalled()
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
|
|
|
await waitFor(() => {
|
|
|
- expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
|
|
|
+ expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
|
|
|
+ expect.stringContaining('one'), expect.stringContaining('two'), expect.stringContaining('three'),
|
|
|
+ ])
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
|
|
|
})
|
|
|
dragAfter('one', 'three')
|
|
|
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
|
|
|
- expect(insertSessionBefore).not.toHaveBeenCalled()
|
|
|
|
|
|
b.view.unmount()
|
|
|
const restored = mount({
|
|
|
useSessions: hook(sessions),
|
|
|
useWorkspaces: hook(workspaceState([])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
expect(restored.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
|
|
|
expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
|
|
|
@@ -1252,13 +1423,11 @@ describe('WorkspaceBrowser', () => {
|
|
|
])
|
|
|
})
|
|
|
|
|
|
- it('still sends the reorder when the dragged row left the group mid-drag', () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
+ it('ignores a drag whose source left the group in flight', () => {
|
|
|
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
|
|
const b = mount({
|
|
|
useSessions: hook(sessions),
|
|
|
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
fireEvent.click(screen.getByText('alpha'))
|
|
|
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
|
|
|
@@ -1271,16 +1440,14 @@ describe('WorkspaceBrowser', () => {
|
|
|
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
|
|
})
|
|
|
fireDrag(two, 'drop', 155)
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two'])
|
|
|
})
|
|
|
|
|
|
it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
|
|
- mount({
|
|
|
+ const b = mount({
|
|
|
useSessions: hook(sessions),
|
|
|
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
fireEvent.click(screen.getByText('alpha'))
|
|
|
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
|
|
@@ -1292,21 +1459,19 @@ describe('WorkspaceBrowser', () => {
|
|
|
fireEvent.dragEnd(one)
|
|
|
// The drag ended: rows no longer accept drops.
|
|
|
fireDrag(two, 'drop', 180)
|
|
|
- expect(insertSessionBefore).not.toHaveBeenCalled()
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
|
|
|
|
|
|
// Bottom half of the last row: append (anchor omitted).
|
|
|
fireEvent.dragStart(one, { dataTransfer })
|
|
|
fireDrag(two, 'dragOver', 180)
|
|
|
fireDrag(two, 'drop', 180)
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
|
|
|
})
|
|
|
|
|
|
it('accepts a document-level drop and commits the last Session marker on drag end', () => {
|
|
|
- const insertSessionBefore = vi.fn(async () => {})
|
|
|
- mount({
|
|
|
+ const b = mount({
|
|
|
useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])),
|
|
|
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
|
|
- insertSessionBefore,
|
|
|
})
|
|
|
fireEvent.click(screen.getByText('alpha'))
|
|
|
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
|
|
@@ -1320,31 +1485,7 @@ describe('WorkspaceBrowser', () => {
|
|
|
fireEvent(document.body, outsideDrop)
|
|
|
expect(outsideDrop.defaultPrevented).toBe(true)
|
|
|
fireEvent.dragEnd(one)
|
|
|
- expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
|
|
|
- })
|
|
|
-
|
|
|
- it('logs and keeps the order when the reorder call rejects', async () => {
|
|
|
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
- try {
|
|
|
- const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
|
|
|
- const sessions = sessionState([summary('one', 2), summary('two', 1)])
|
|
|
- mount({
|
|
|
- useSessions: hook(sessions),
|
|
|
- useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
|
|
|
- insertSessionBefore,
|
|
|
- })
|
|
|
- fireEvent.click(screen.getByText('alpha'))
|
|
|
- const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
|
|
- two.getBoundingClientRect = () => ({
|
|
|
- top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
|
|
- })
|
|
|
- const dataTransfer = dragData()
|
|
|
- fireEvent.dragStart(one, { dataTransfer })
|
|
|
- fireDrag(two, 'drop', 180)
|
|
|
- await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
|
|
|
- } finally {
|
|
|
- warn.mockRestore()
|
|
|
- }
|
|
|
+ expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
|
|
|
})
|
|
|
|
|
|
it('renames a workspace through the row menu dialog', async () => {
|