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
85 changes: 84 additions & 1 deletion .playwright/scripts/__tests__/workbench.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ test('builtin working copies back up continued edits, veto closure and recover a
await expect.poll(readBackup).toBe(beforeUndo)
await expect(editor.locator('.view-lines')).toContainText('Second edit while dirty.')
expect(await fs.readFile(document, 'utf8')).toBe('Original document.\n')
await page.getByTestId('task-context-panel').getByRole('button', { name: '关闭文件', exact: true }).click()
await page.getByTestId('task-context-panel').getByRole('tab', { name: '文件', exact: true }).click({ button: 'right' })
await page.locator('.n-dropdown-menu:visible').getByText('关闭全部', { exact: true }).click()
const close = page.locator('.n-dialog').filter({ hasText: '关闭前保存更改?' })
await close.getByRole('button', { name: '取消', exact: true }).click()
await expect(close).toBeHidden()
Expand Down Expand Up @@ -346,3 +347,85 @@ test('sidebar resizing and collapsing preserve the current draft and persist who
await expect(restarted.page.locator('.desktop-chat-composer__prosemirror:visible')).toHaveText('Keep this draft while changing the layout.')
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})

test('resource tab menus pin, close by position and preserve pins across restart', async ({ buddy }, testInfo) => {
const instance = await buddy.createInstance('resource-tab-menu')
let { app, page, diagnostics } = await instance.launch()
await expect(page.getByRole('textbox', { name: '对话消息', exact: true })).toBeVisible()
await page.keyboard.press('Control+Shift+P')
await page.getByPlaceholder('输入命令名称').fill('浏览器')
await page.getByPlaceholder('输入命令名称').press('Enter')
const panel = () => page.getByTestId('task-context-panel')
const tabs = () => panel().getByRole('tab')
const menu = () => page.locator('.n-dropdown-menu:visible')
async function add() {
const count = await tabs().count()
await page.getByTestId('context-add-tab').click()
await page.getByRole('menuitem', { name: '浏览器', exact: true }).click()
await expect(tabs()).toHaveCount(count + 1)
}
async function action(index, label) {
await tabs().nth(index).click({ button: 'right' })
await expect(menu().getByText(label, { exact: true }).locator('..')).not.toHaveClass(/disabled/)
await menu().getByText(label, { exact: true }).click()
}
await expect(tabs()).toHaveCount(1)
await add()
await add()
await add()
const ids = await tabs().evaluateAll(elements => elements.map(element => element.id))
await action(1, '固定')
await expect(tabs().first()).toHaveAttribute('id', ids[1])
await expect(panel().locator('[data-pinned="true"]')).toHaveCount(1)
await expect(tabs().last()).toHaveAttribute('aria-selected', 'true')
await tabs().nth(2).click({ button: 'right' })
await expect(menu().locator('.n-dropdown-option-body__label')).toHaveText(['固定', '关闭', '关闭其他', '关闭左侧', '关闭右侧', '关闭全部'])
await testInfo.attach('resource-tab-menu', { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' })
await expect(menu().getByText('关闭左侧', { exact: true }).locator('..')).not.toHaveClass(/disabled/)
await menu().getByText('关闭左侧', { exact: true }).click()
await expect(tabs()).toHaveCount(3)
expect(await tabs().evaluateAll(elements => elements.map(element => element.id))).toEqual([ids[1], ids[2], ids[3]])
await action(1, '关闭右侧')
await expect(tabs()).toHaveCount(2)
expect(await tabs().evaluateAll(elements => elements.map(element => element.id))).toEqual([ids[1], ids[2]])
await add()
await action(1, '关闭其他')
await expect(tabs()).toHaveCount(2)
await action(1, '关闭全部')
await expect(tabs()).toHaveCount(1)
await expect(tabs().first()).toHaveAttribute('id', ids[1])
await tabs().first().focus()
await page.keyboard.press('Shift+F10')
await expect(menu().getByText('取消固定', { exact: true })).toBeVisible()
for (const label of ['关闭其他', '关闭左侧', '关闭右侧', '关闭全部'])
await expect(menu().getByText(label, { exact: true }).locator('..')).toHaveClass(/disabled/)
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => window.lexoraDesktop.themes.request({ action: 'preference', preference: { id: `lexora.themes.classic-${theme}` } }), theme)
await expect(page.locator('html')).toHaveAttribute('data-buddy-theme', theme)
await testInfo.attach(`resource-tab-menu-${theme}`, { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' })
}
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 640))
await page.keyboard.press('Escape')
await tabs().first().click({ button: 'right' })
await expect(menu().getByText('关闭全部', { exact: true })).toBeInViewport()
await testInfo.attach('resource-tab-menu-narrow', { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' })
await page.keyboard.press('Escape')
await expect(menu()).toBeHidden()
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
await instance.stop()
;({ page, diagnostics } = await instance.launch())
const toggle = page.getByTestId('context-panel-toggle')
await expect(toggle).toBeVisible()
if (await toggle.getAttribute('aria-expanded') === 'false')
await toggle.click()
await expect(panel().locator('[data-pinned="true"]')).toHaveCount(1)
await action(0, '取消固定')
await expect(panel().locator('[data-pinned="true"]')).toHaveCount(0)
await action(0, '固定')
await panel().getByRole('button', { name: '取消固定', exact: true }).click()
await expect(tabs()).toHaveCount(1)
await expect(panel().locator('[data-pinned="true"]')).toHaveCount(0)
await panel().getByRole('button', { name: '关闭新标签页', exact: true }).click()
await expect(tabs()).toHaveCount(0)
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})
7 changes: 7 additions & 0 deletions apps/buddy/src/i18n/locales/en-US/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ export default {
'desktop.context.browserCertificateFailed': 'The page’s security certificate could not be verified. The connection was blocked.',
'desktop.context.browserNavigationBlocked': 'Access to this address or resource was blocked. Check the address and permissions.',
'desktop.context.browserPageUnavailable': 'The page stopped responding or closed unexpectedly. Please try again.',
'desktop.context.pin': 'Pin',
'desktop.context.unpin': 'Unpin',
'desktop.context.close': 'Close',
'desktop.context.closeOthers': 'Close Others',
'desktop.context.closeLeft': 'Close to the Left',
'desktop.context.closeRight': 'Close to the Right',
'desktop.context.closeAll': 'Close All',
'desktop.context.closeTab': 'Close {name}',
'desktop.context.changedFiles': '{count} files',
'desktop.context.changedFilesLabel': 'Files changed in this run',
Expand Down
7 changes: 7 additions & 0 deletions apps/buddy/src/i18n/locales/zh-CN/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ export default {
'desktop.context.browserCertificateFailed': '网页的安全证书验证失败,连接已被阻止。',
'desktop.context.browserNavigationBlocked': '此地址或资源的访问已被阻止,请检查地址与访问权限。',
'desktop.context.browserPageUnavailable': '网页已停止响应或意外退出,请重试。',
'desktop.context.pin': '固定',
'desktop.context.unpin': '取消固定',
'desktop.context.close': '关闭',
'desktop.context.closeOthers': '关闭其他',
'desktop.context.closeLeft': '关闭左侧',
'desktop.context.closeRight': '关闭右侧',
'desktop.context.closeAll': '关闭全部',
'desktop.context.closeTab': '关闭{name}',
'desktop.context.changedFiles': '{count} 个文件',
'desktop.context.changedFilesLabel': '本轮变更文件',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { z } from 'zod'
const identity = z.string().min(1).max(256)
const scopeSchema = z.union([z.literal('independent'), z.literal('workspace'), z.templateLiteral(['task:', z.string().min(1)]), z.templateLiteral(['draft:', z.string().min(1)])])
const selectionSchema = z.tuple([z.union([scopeSchema, z.templateLiteral(['space:', z.string().min(1)])]), identity])
const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional() })
const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional(), pinned: z.boolean().optional() })
const tabSchema = z.discriminatedUnion('kind', [
base.extend({ kind: z.literal('browser'), conversationId: identity.nullable(), browserKey: identity.optional() }),
base.extend({ kind: z.literal('files'), target: spaceFileTargetSchema, treeTarget: z.object({ path: z.string().max(4096), kind: z.enum(['file', 'directory']) }).optional(), rootName: z.string() }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function contextPanelSelectionScope(mode: DesktopContextPanelMode, scope:
}

interface ContextTabSource {
pinned?: boolean
scope: ContextPanelScope
source?: ContextPanelSource
}
Expand Down Expand Up @@ -63,6 +64,7 @@ export interface TaskViewContextTab extends ContextTabSource {
}

export interface ContextPanelTab {
pinned?: boolean
dirty?: boolean
id: string
title: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,61 @@ function artifact(artifactId = 'report'): LocalArtifact {
}

describe('resource panel close', () => {
it('keeps pins when reopening resources and restoring snapshots, and isolates bulk close to visible tabs', async () => {
const activeConversationId = shallowRef<string | null>('task-a')
const f = fixture({ activeConversationId })
f.panel.openView('first', 'First')
f.panel.openView('pinned', 'Pinned')
f.panel.setTabPinned('pinned', true)
f.panel.openView('pinned', 'Updated')
expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['pinned', 'first'])
activeConversationId.value = 'task-b'
f.panel.openView('hidden', 'Hidden')
activeConversationId.value = 'task-a'
const restored = fixture()
restored.panel.restoreSnapshot(JSON.parse(JSON.stringify(f.panel.snapshot())))
expect(restored.panel.tabs.value.map(tab => [tab.id, !!tab.pinned])).toEqual([['pinned', true], ['first', false]])
await restored.panel.closeTabs(['pinned', 'first', 'hidden'])
expect(restored.panel.allTabs.value.map(tab => tab.id)).toEqual(['pinned', 'hidden'])
expect(await restored.panel.closeTab('pinned')).toBe(true)
expect(restored.panel.allTabs.value.map(tab => tab.id)).toEqual(['hidden'])
})

it('stops a batch at a cancelled save and preserves the remaining views', async () => {
const f = fixture({ closeView: async id => id !== 'dirty' })
for (const id of ['clean', 'dirty', 'after'])
f.panel.openView(id, id)
await f.panel.closeTabs(['clean', 'dirty', 'after'])
expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['dirty', 'after'])
})

it('preserves tabs opened or pinned while a batch waits for a close', async () => {
const gate = deferred<boolean>()
const f = fixture({ closeView: () => gate.promise })
for (const id of ['first', 'second'])
f.panel.openView(id, id)
const closing = f.panel.closeTabs(['first', 'second'])
f.panel.setTabPinned('second', true)
f.panel.openView('new', 'New')
gate.resolve(true)
await closing
expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['second', 'new'])
})

it('stops the remaining batch after switching away and back to the scope', async () => {
const gate = deferred<boolean>()
const activeConversationId = shallowRef<string | null>('task-a')
const f = fixture({ activeConversationId, closeView: () => gate.promise })
for (const id of ['first', 'second'])
f.panel.openView(id, id)
const closing = f.panel.closeTabs(['first', 'second'])
activeConversationId.value = 'task-b'
activeConversationId.value = 'task-a'
gate.resolve(true)
await closing
expect(f.panel.tabs.value.map(tab => tab.id)).toEqual(['second'])
})

it.each(['space', 'independent'] as const)('records a user close with the original source only after the last visible tab closes in %s mode', async (mode) => {
const f = fixture({
mode: shallowRef(mode),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ export function useContextPanelTabs(options: {
}

function scopeTabs(scope: ContextPanelSelectionScope) {
if (scope === 'independent')
return resources.value
return resources.value.filter(tab => scope.startsWith('space:') ? selectionFor(tab.scope, 'space') === scope : tab.scope === scope)
const visible = scope === 'independent' ? resources.value : resources.value.filter(tab => scope.startsWith('space:') ? selectionFor(tab.scope, 'space') === scope : tab.scope === scope)
return visible.toSorted((a, b) => Number(!!b.pinned) - Number(!!a.pinned))
}

function selectedTab(scope: ContextPanelSelectionScope) {
Expand All @@ -69,7 +68,7 @@ export function useContextPanelTabs(options: {
if (discardedScopes.has(tab.scope))
return
resources.value = resources.value.some(item => item.id === tab.id)
? resources.value.map(item => item.id === tab.id ? tab : item)
? resources.value.map(item => item.id === tab.id ? { ...tab, pinned: item.pinned } : item)
: [...resources.value, tab]
if (activate)
selections.set(selectionFor(tab.scope), tab.id)
Expand All @@ -94,6 +93,11 @@ export function useContextPanelTabs(options: {
resources.value = resources.value.map(transform)
}

function setPinned(id: string, pinned: boolean) {
if (tabs.value.some(tab => tab.id === id))
update(tab => tab.id === id ? { ...tab, pinned } : tab)
}

function retain(predicate: (tab: TaskContextTab) => boolean) {
for (const tab of resources.value) {
if (!predicate(tab))
Expand Down Expand Up @@ -130,5 +134,5 @@ export function useContextPanelTabs(options: {
}
}

return { snapshot, restoreSelections, activeTab: readonly(activeTab), tabs: readonly(tabs), resources: readonly(resources), scope: readonly(scope), selectionScope: readonly(selectionScope), select, put, close, update, retain, discard, discardDraft, adoptDraft }
return { snapshot, restoreSelections, activeTab: readonly(activeTab), tabs: readonly(tabs), resources: readonly(resources), scope: readonly(scope), selectionScope: readonly(selectionScope), select, put, close, setPinned, update, retain, discard, discardDraft, adoptDraft }
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) {
discardDraft: store.discardDraft,
addBrowser,
closeTab: store.close,
setTabPinned: store.setPinned,
discardConversation: (id: string) => store.discard(taskContextPanelScope(id)),
hasTab,
openArtifact,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,30 @@ export function useTaskResourcePanel(options: TaskResourcePanelOptions) {
return accepted.promise
}

async function closeTabs(tabIds: readonly string[]): Promise<void> {
const requested = new Set(tabIds)
const targets = taskContext.tabs.value.filter(tab => requested.has(tab.id) && !tab.pinned)
let cancelled = false
const stop = watch(taskContext.selectionScope, () => cancelled = true, { flush: 'sync' })
try {
for (const target of targets) {
if (disposed || cancelled)
break
const tab = taskContext.tabs.value.find(tab => tab.id === target.id)
if (!tab || tab.pinned)
continue
if (!await closeTab(tab.id))
break
}
}
catch (error) {
options.onError(error)
}
finally {
stop()
}
}

async function closeResource(tabId: string): Promise<boolean> {
const tab = taskContext.allTabs.value.find(item => item.id === tabId)
if (!tab)
Expand Down Expand Up @@ -350,6 +374,7 @@ export function useTaskResourcePanel(options: TaskResourcePanelOptions) {
canPreviewFile,
previewFile,
closeTab,
closeTabs,
discardDraft: (id: string) => {
operation.value += 1
interaction += 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ defineProps<{
const emit = defineEmits<{
add: [id: string]
closeTab: [tabId: string]
closeTabs: [tabIds: readonly string[]]
pinTab: [tabId: string, pinned: boolean]
selectTab: [tabId: string]
}>()
defineSlots<{ 'default'?: () => unknown, 'header-actions'?: () => unknown, 'toolbar'?: () => unknown }>()
</script>

<template>
<WorkbenchResourcePanel :active-tab-id="activeTabId" :tabs="tabs" :language="language" :actions="actions" @add="emit('add', $event)" @close-tab="emit('closeTab', $event)" @select-tab="emit('selectTab', $event)">
<WorkbenchResourcePanel :active-tab-id="activeTabId" :tabs="tabs" :language="language" :actions="actions" @add="emit('add', $event)" @close-tab="emit('closeTab', $event)" @close-tabs="emit('closeTabs', $event)" @pin-tab="(id, pinned) => emit('pinTab', id, pinned)" @select-tab="emit('selectTab', $event)">
<template #icon="{ tab }">
<FileIcon v-if="tab.icon === 'file'" :name="tab.fileName ?? tab.title" />
<FolderIcon v-else-if="tab.icon === 'folder'" class="desktop-task-context-panel__folder-icon w-4 h-4 flex-none" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,18 @@ const browserApi: DesktopBrowserApi = { ...browser, ensureSession: (conversation
const activeTab = computed(() => props.panel.activeTab.value)
const tabs = computed<ContextPanelTab[]>(() => props.panel.tabs.value.map((tab) => {
if (tab.kind === 'view')
return { id: tab.id, title: tab.label, dirty: tab.dirty, icon: 'file' }
return { id: tab.id, pinned: tab.pinned, title: tab.label, dirty: tab.dirty, icon: 'file' }
if (tab.kind === 'artifact')
return { id: tab.id, title: tab.artifact.name, icon: tab.artifact.kind === 'directory' ? 'folder' : 'file', fileName: tab.artifact.name }
return { id: tab.id, pinned: tab.pinned, title: tab.artifact.name, icon: tab.artifact.kind === 'directory' ? 'folder' : 'file', fileName: tab.artifact.name }
if (tab.kind === 'files')
return { id: tab.id, title: t('desktop.context.files'), icon: 'folder' }
return { id: tab.id, pinned: tab.pinned, title: t('desktop.context.files'), icon: 'folder' }
if (tab.kind === 'changes')
return { id: tab.id, title: t('desktop.context.changes'), icon: 'changes' }
return { id: tab.id, pinned: tab.pinned, title: t('desktop.context.changes'), icon: 'changes' }
const state = props.panel.browserStates.value[tab.id]
const title = !state || state.url === 'about:blank'
? t('desktop.context.browserNewTab')
: state.title.trim() || t('desktop.context.browser')
return { id: tab.id, title, icon: 'browser' }
return { id: tab.id, pinned: tab.pinned, title, icon: 'browser' }
}))
async function add(id: string) {
try {
Expand All @@ -57,7 +57,7 @@ async function add(id: string) {

<template>
<DesktopFileSpacePicker :show="panel.fileSpacePickerOpen.value" :spaces="panel.filePickerSpaces.value" :language="language" @update:show="panel.setFileSpacePickerOpen" @select="panel.openFiles" />
<DesktopTaskContextPanel :active-tab-id="activeTab?.id ?? null" :tabs="tabs" :language="language" :actions="actions" @add="add" @close-tab="panel.closeTab" @select-tab="panel.selectTab">
<DesktopTaskContextPanel :active-tab-id="activeTab?.id ?? null" :tabs="tabs" :language="language" :actions="actions" @add="add" @close-tab="panel.closeTab" @close-tabs="panel.closeTabs" @pin-tab="panel.setTabPinned" @select-tab="panel.selectTab">
<template v-if="$slots['header-actions']" #header-actions>
<slot name="header-actions" />
</template>
Expand Down
Loading