mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(agent-explorer): support multi-select delete in document tree (#15125)
* ✨ feat(agent-explorer): support multi-select delete in document tree - Right-click on a multi-selected row deletes the whole selection; dedupe descendants when an ancestor folder is also selected - Reserve chevron slot in SkillsList rows so atomic and bundled skills align - Centralize EMPTY_ARRAY (typed `never[]`, frozen) in @lobechat/const * ♻️ refactor: migrate delete confirm dialog from antd modal to confirmModal * ✅ test: stabilize bun vitest environment * 🔧 ci: avoid authenticated checkout for PR tests
This commit is contained in:
@@ -10,6 +10,10 @@ inputs:
|
||||
description: Pass-through to actions/setup-node package-manager-cache
|
||||
required: false
|
||||
default: 'false'
|
||||
bun-version:
|
||||
description: Bun version
|
||||
required: false
|
||||
default: '1.3.2'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,6 +25,8 @@ runs:
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ inputs.bun-version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -59,7 +59,14 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
@@ -35,7 +35,15 @@ jobs:
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -101,7 +109,15 @@ jobs:
|
||||
name: Test App (shard ${{ matrix.shard }}/3)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -128,7 +144,15 @@ jobs:
|
||||
name: Merge and Upload App Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -161,7 +185,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
@@ -207,7 +239,15 @@ jobs:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
env:
|
||||
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
run: |
|
||||
git init .
|
||||
git remote add origin "https://github.com/${REPOSITORY}.git"
|
||||
git fetch --no-tags --depth=1 origin "${REF_SHA}"
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
- name: Setup environment
|
||||
uses: ./.github/actions/setup-env
|
||||
|
||||
@@ -962,6 +962,7 @@
|
||||
"workingPanel.resources.deleteError": "Failed to delete document",
|
||||
"workingPanel.resources.deleteSuccess": "Document deleted",
|
||||
"workingPanel.resources.deleteTitle": "Delete document?",
|
||||
"workingPanel.resources.deleteTitleMulti": "Delete {{count}} items?",
|
||||
"workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
|
||||
"workingPanel.resources.error": "Failed to load resources",
|
||||
"workingPanel.resources.filter.documents": "Documents",
|
||||
@@ -974,6 +975,7 @@
|
||||
"workingPanel.resources.renameError": "Failed to rename document",
|
||||
"workingPanel.resources.renameSuccess": "Document renamed",
|
||||
"workingPanel.resources.tree.createError": "Failed to create",
|
||||
"workingPanel.resources.tree.deleteSelected": "Delete selected ({{count}})",
|
||||
"workingPanel.resources.tree.moveError": "Failed to move",
|
||||
"workingPanel.resources.tree.newDocument": "New document",
|
||||
"workingPanel.resources.tree.newFolder": "New folder",
|
||||
|
||||
@@ -962,6 +962,7 @@
|
||||
"workingPanel.resources.deleteError": "删除失败",
|
||||
"workingPanel.resources.deleteSuccess": "文档已删除",
|
||||
"workingPanel.resources.deleteTitle": "删除该文档?",
|
||||
"workingPanel.resources.deleteTitleMulti": "删除 {{count}} 项?",
|
||||
"workingPanel.resources.empty": "暂无网页。本智能体抓取的网页将显示在这里。",
|
||||
"workingPanel.resources.error": "资源加载失败",
|
||||
"workingPanel.resources.filter.documents": "文档",
|
||||
@@ -974,6 +975,7 @@
|
||||
"workingPanel.resources.renameError": "重命名失败",
|
||||
"workingPanel.resources.renameSuccess": "重命名成功",
|
||||
"workingPanel.resources.tree.createError": "创建失败",
|
||||
"workingPanel.resources.tree.deleteSelected": "删除所选({{count}})",
|
||||
"workingPanel.resources.tree.moveError": "移动失败",
|
||||
"workingPanel.resources.tree.newDocument": "新建文档",
|
||||
"workingPanel.resources.tree.newFolder": "新建文件夹",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Type `never[]` so `x ?? EMPTY_ARRAY` doesn't widen `x`'s element type.
|
||||
// Frozen to prevent accidental mutation of the shared reference.
|
||||
export const EMPTY_ARRAY: never[] = Object.freeze([]) as never[];
|
||||
@@ -4,6 +4,7 @@ export * from './desktop';
|
||||
export * from './desktopGlobalShortcuts';
|
||||
export * from './discover';
|
||||
export * from './editor';
|
||||
export * from './empty';
|
||||
export * from './fetch';
|
||||
export * from './file';
|
||||
export * from './interests';
|
||||
|
||||
@@ -28,6 +28,10 @@ vi.mock('@lobehub/ui', () => ({
|
||||
Text: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/ui/base-ui', () => ({
|
||||
confirmModal: modalConfirm,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
|
||||
@@ -194,9 +194,17 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
|
||||
const isFolder = !!node.isFolder;
|
||||
const targetParentId = isFolder ? node.id : (node.parentId ?? null);
|
||||
|
||||
// Right-click on a row that's part of the current multi-selection acts
|
||||
// on the whole selection; otherwise it targets only the right-clicked
|
||||
// row (which matches typical file-tree UX where right-clicking outside
|
||||
// the selection narrows the action).
|
||||
const selectedIds = treeRef.current?.getSelectedIds() ?? [];
|
||||
const isMulti = selectedIds.length > 1 && selectedIds.includes(node.id);
|
||||
const deleteIds = isMulti ? selectedIds : [node.id];
|
||||
|
||||
const items: NonNullable<MenuProps['items']> = [];
|
||||
|
||||
if (isFolder && !isSkill) {
|
||||
if (isFolder && !isSkill && !isMulti) {
|
||||
items.push(
|
||||
{
|
||||
key: 'new-folder',
|
||||
@@ -212,7 +220,7 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSkill) {
|
||||
if (!isSkill && !isMulti) {
|
||||
items.push({
|
||||
key: 'rename',
|
||||
label: t('workingPanel.resources.tree.rename'),
|
||||
@@ -224,8 +232,10 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
|
||||
danger: true,
|
||||
icon: <Trash2Icon size={14} />,
|
||||
key: 'delete',
|
||||
label: t('delete', { ns: 'common' }),
|
||||
onClick: () => ops.deleteDocument(node.id),
|
||||
label: isMulti
|
||||
? t('workingPanel.resources.tree.deleteSelected', { count: deleteIds.length })
|
||||
: t('delete', { ns: 'common' }),
|
||||
onClick: () => ops.deleteDocuments(deleteIds),
|
||||
});
|
||||
|
||||
return items;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmModal } from '@lobehub/ui/base-ui';
|
||||
import { App } from 'antd';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -44,7 +45,7 @@ export interface CreateOptions {
|
||||
export interface DocumentTreeOps {
|
||||
createDocument: (parentId: string | null, opts?: CreateOptions) => Promise<void>;
|
||||
createFolder: (parentId: string | null, opts?: CreateOptions) => Promise<void>;
|
||||
deleteDocument: (id: string) => void;
|
||||
deleteDocuments: (ids: string[]) => void;
|
||||
moveDocument: (params: {
|
||||
sourceIds: string[];
|
||||
sourceNodes: { data?: AgentDocumentItem }[];
|
||||
@@ -60,7 +61,7 @@ export const useDocumentTreeOps = ({
|
||||
topicId,
|
||||
}: UseDocumentTreeOpsArgs): DocumentTreeOps => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const { message, modal } = App.useApp();
|
||||
const { message } = App.useApp();
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
@@ -354,41 +355,70 @@ export const useDocumentTreeOps = ({
|
||||
[agentId, buildItemPath, buildParentPathFromRowId, byRowId, message, mutate, t],
|
||||
);
|
||||
|
||||
const deleteDocument = useCallback(
|
||||
(id: string) => {
|
||||
const target = dataRef.current.find((doc) => doc.id === id);
|
||||
if (!target) return;
|
||||
if (isProtectedManagedSkillItem(target, dataRef.current)) return;
|
||||
const deleteDocuments = useCallback(
|
||||
(ids: string[]) => {
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
const resolved = uniqueIds
|
||||
.map((id) => dataRef.current.find((doc) => doc.id === id))
|
||||
.filter((doc): doc is AgentDocumentItem => !!doc)
|
||||
.filter((doc) => !isProtectedManagedSkillItem(doc, dataRef.current));
|
||||
|
||||
modal.confirm({
|
||||
if (resolved.length === 0) return;
|
||||
|
||||
// When a selected folder is an ancestor of another selection, skip the
|
||||
// descendant — the folder's recursive delete already covers it.
|
||||
const docById = new Map(dataRef.current.map((doc) => [doc.documentId, doc]));
|
||||
const folderDocIds = new Set(
|
||||
resolved.filter((doc) => doc.isFolder && !doc.isSkillBundle).map((doc) => doc.documentId),
|
||||
);
|
||||
const hasAncestorIn = (item: AgentDocumentItem, ancestors: Set<string>): boolean => {
|
||||
let cursor = item.parentId;
|
||||
while (cursor) {
|
||||
if (ancestors.has(cursor)) return true;
|
||||
cursor = docById.get(cursor)?.parentId ?? null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const targets = resolved.filter((doc) => !hasAncestorIn(doc, folderDocIds));
|
||||
|
||||
if (targets.length === 0) return;
|
||||
|
||||
confirmModal({
|
||||
cancelText: t('cancel', { ns: 'common' }),
|
||||
centered: true,
|
||||
content: t('workingPanel.resources.deleteConfirm'),
|
||||
okButtonProps: { danger: true, type: 'primary' },
|
||||
okButtonProps: { danger: true },
|
||||
okText: t('delete', { ns: 'common' }),
|
||||
onOk: () => {
|
||||
// Use isFolder rather than the bundle-inclusive isFolder field —
|
||||
// skill bundles are deleted via the bundle row (single doc), not
|
||||
// via path-based recursive removal.
|
||||
const isFolder = target.isFolder && !target.isSkillBundle;
|
||||
const folderPath = isFolder ? buildItemPath(target) : null;
|
||||
if (isFolder && folderPath === null) {
|
||||
message.error(t('workingPanel.resources.tree.parentMissing'));
|
||||
return;
|
||||
}
|
||||
const removedIds = new Set<string>();
|
||||
const plans: Array<
|
||||
| { kind: 'folder'; path: string; target: AgentDocumentItem }
|
||||
| { kind: 'row'; target: AgentDocumentItem }
|
||||
> = [];
|
||||
|
||||
// Collect target + all descendants (parentId chain via documentId) for
|
||||
// optimistic recursive removal. Server is the source of truth and a
|
||||
// final `mutate()` reconciles either way.
|
||||
const removedIds = new Set<string>([target.id]);
|
||||
if (isFolder) {
|
||||
const queue: string[] = [target.documentId];
|
||||
while (queue.length > 0) {
|
||||
const parentDocId = queue.shift()!;
|
||||
for (const doc of dataRef.current) {
|
||||
if (doc.parentId === parentDocId && !removedIds.has(doc.id)) {
|
||||
removedIds.add(doc.id);
|
||||
queue.push(doc.documentId);
|
||||
for (const target of targets) {
|
||||
// Skill bundles are removed via the bundle row, not path-based
|
||||
// recursive removal.
|
||||
const isFolder = target.isFolder && !target.isSkillBundle;
|
||||
if (isFolder) {
|
||||
const folderPath = buildItemPath(target);
|
||||
if (!folderPath) {
|
||||
message.error(t('workingPanel.resources.tree.parentMissing'));
|
||||
return;
|
||||
}
|
||||
plans.push({ kind: 'folder', path: folderPath, target });
|
||||
} else {
|
||||
plans.push({ kind: 'row', target });
|
||||
}
|
||||
removedIds.add(target.id);
|
||||
if (isFolder) {
|
||||
const queue: string[] = [target.documentId];
|
||||
while (queue.length > 0) {
|
||||
const parentDocId = queue.shift()!;
|
||||
for (const doc of dataRef.current) {
|
||||
if (doc.parentId === parentDocId && !removedIds.has(doc.id)) {
|
||||
removedIds.add(doc.id);
|
||||
queue.push(doc.documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,49 +429,62 @@ export const useDocumentTreeOps = ({
|
||||
revalidate: false,
|
||||
});
|
||||
|
||||
// Returning synchronously closes the modal immediately; the server
|
||||
// call runs in the background and toasts on success/rollback.
|
||||
void (async () => {
|
||||
try {
|
||||
if (isFolder) {
|
||||
await agentDocumentService.deleteByPath({
|
||||
agentId,
|
||||
path: folderPath!,
|
||||
recursive: true,
|
||||
});
|
||||
} else {
|
||||
await agentDocumentService.removeDocument({
|
||||
agentId,
|
||||
documentId: target.documentId,
|
||||
id: target.id,
|
||||
topicId,
|
||||
});
|
||||
}
|
||||
await mutate();
|
||||
} catch (error) {
|
||||
const errors: Error[] = [];
|
||||
await Promise.all(
|
||||
plans.map(async (plan) => {
|
||||
try {
|
||||
if (plan.kind === 'folder') {
|
||||
await agentDocumentService.deleteByPath({
|
||||
agentId,
|
||||
path: plan.path,
|
||||
recursive: true,
|
||||
});
|
||||
} else {
|
||||
await agentDocumentService.removeDocument({
|
||||
agentId,
|
||||
documentId: plan.target.documentId,
|
||||
id: plan.target.id,
|
||||
topicId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (errors.length === plans.length) {
|
||||
mutate(snapshot, { revalidate: false });
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? `${t('workingPanel.resources.deleteError')}: ${error.message}`
|
||||
: t('workingPanel.resources.deleteError'),
|
||||
);
|
||||
const detail = errors.map((e) => e.message).join('; ');
|
||||
message.error(`${t('workingPanel.resources.deleteError')}: ${detail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await mutate();
|
||||
if (errors.length > 0) {
|
||||
const detail = errors.map((e) => e.message).join('; ');
|
||||
message.error(`${t('workingPanel.resources.deleteError')}: ${detail}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
title: t('workingPanel.resources.deleteTitle'),
|
||||
title:
|
||||
targets.length > 1
|
||||
? t('workingPanel.resources.deleteTitleMulti', { count: targets.length })
|
||||
: t('workingPanel.resources.deleteTitle'),
|
||||
});
|
||||
},
|
||||
[agentId, buildItemPath, message, modal, mutate, t, topicId],
|
||||
[agentId, buildItemPath, message, mutate, t, topicId],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
createDocument,
|
||||
createFolder,
|
||||
deleteDocument,
|
||||
deleteDocuments,
|
||||
moveDocument,
|
||||
renameDocument,
|
||||
}),
|
||||
[createDocument, createFolder, deleteDocument, moveDocument, renameDocument],
|
||||
[createDocument, createFolder, deleteDocuments, moveDocument, renameDocument],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMPTY_ARRAY } from '@lobechat/const';
|
||||
import { Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
|
||||
import { SkillsIcon } from '@lobehub/ui/icons';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
@@ -225,11 +226,12 @@ interface SkillRowProps {
|
||||
onOpenFile?: (relativePath: string) => void;
|
||||
onOpenSkill?: () => void;
|
||||
onToggle: () => void;
|
||||
reserveChevronSlot: boolean;
|
||||
}
|
||||
|
||||
const SkillRow = memo<SkillRowProps>(
|
||||
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle }) => {
|
||||
const files = item.files ?? [];
|
||||
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle, reserveChevronSlot }) => {
|
||||
const files = item.files ?? EMPTY_ARRAY;
|
||||
const hasFiles = files.length > 0;
|
||||
const tree = useMemo(() => buildSkillTree(files), [files]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
@@ -277,9 +279,9 @@ const SkillRow = memo<SkillRowProps>(
|
||||
size={14}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : (
|
||||
) : reserveChevronSlot ? (
|
||||
<span style={{ flexShrink: 0, height: 20, width: 20 }} />
|
||||
)}
|
||||
) : null}
|
||||
<Icon className={styles.itemIcon} icon={SkillsIcon} size={14} />
|
||||
<Text ellipsis style={{ color: 'inherit', flex: 1, minWidth: 0 }} onClick={onOpenSkill}>
|
||||
{item.name}
|
||||
@@ -321,6 +323,11 @@ const SkillsList = memo<SkillsListProps>(({ items, onOpenFile, onOpenSkill, onSk
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reserveChevronSlot = useMemo(
|
||||
() => items.some((item) => (item.files?.length ?? 0) > 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<Flexbox gap={2}>
|
||||
{items.map((item) => (
|
||||
@@ -328,6 +335,7 @@ const SkillsList = memo<SkillsListProps>(({ items, onOpenFile, onOpenSkill, onSk
|
||||
expanded={expanded.has(item.id)}
|
||||
item={item}
|
||||
key={item.id}
|
||||
reserveChevronSlot={reserveChevronSlot}
|
||||
onDragStart={onSkillDragStart ? (event) => onSkillDragStart(item, event) : undefined}
|
||||
onOpenFile={onOpenFile ? (relativePath) => onOpenFile(item, relativePath) : undefined}
|
||||
onOpenSkill={onOpenSkill ? () => onOpenSkill(item) : undefined}
|
||||
|
||||
@@ -1019,6 +1019,7 @@ export default {
|
||||
'workingPanel.resources.deleteError': 'Failed to delete document',
|
||||
'workingPanel.resources.deleteSuccess': 'Document deleted',
|
||||
'workingPanel.resources.deleteTitle': 'Delete document?',
|
||||
'workingPanel.resources.deleteTitleMulti': 'Delete {{count}} items?',
|
||||
'workingPanel.resources.empty': 'No webpages. Webpages crawled in this agent will show up here.',
|
||||
'workingPanel.resources.error': 'Failed to load resources',
|
||||
'workingPanel.resources.filter.documents': 'Documents',
|
||||
@@ -1031,6 +1032,7 @@ export default {
|
||||
'workingPanel.resources.renameError': 'Failed to rename document',
|
||||
'workingPanel.resources.renameSuccess': 'Document renamed',
|
||||
'workingPanel.resources.tree.createError': 'Failed to create',
|
||||
'workingPanel.resources.tree.deleteSelected': 'Delete selected ({{count}})',
|
||||
'workingPanel.resources.tree.moveError': 'Failed to move',
|
||||
'workingPanel.resources.tree.parentMissing': 'Parent folder is unavailable',
|
||||
'workingPanel.resources.tree.newDocument': 'New document',
|
||||
|
||||
+50
-1
@@ -7,7 +7,7 @@ import { theme } from 'antd';
|
||||
import i18n from 'i18next';
|
||||
import { enableMapSet, enablePatches } from 'immer';
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
import chat from '@/locales/default/chat';
|
||||
import common from '@/locales/default/common';
|
||||
@@ -15,6 +15,52 @@ import discover from '@/locales/default/discover';
|
||||
import home from '@/locales/default/home';
|
||||
import oauth from '@/locales/default/oauth';
|
||||
|
||||
class TestMemoryStorage implements Storage {
|
||||
private readonly store = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.store.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const installTestStorage = () => {
|
||||
const localStorage = new TestMemoryStorage();
|
||||
const sessionStorage = new TestMemoryStorage();
|
||||
|
||||
Object.defineProperties(globalThis, {
|
||||
Storage: { configurable: true, value: TestMemoryStorage, writable: true },
|
||||
localStorage: { configurable: true, value: localStorage, writable: true },
|
||||
sessionStorage: { configurable: true, value: sessionStorage, writable: true },
|
||||
});
|
||||
|
||||
if (typeof globalThis.window !== 'undefined') {
|
||||
Object.defineProperties(window, {
|
||||
localStorage: { configurable: true, value: localStorage, writable: true },
|
||||
sessionStorage: { configurable: true, value: sessionStorage, writable: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Enable Immer MapSet plugin so store code using Map/Set in produce() works in tests
|
||||
enablePatches();
|
||||
enableMapSet();
|
||||
@@ -50,6 +96,9 @@ if (typeof globalThis.window === 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
installTestStorage();
|
||||
beforeEach(installTestStorage);
|
||||
|
||||
// remove antd hash on test
|
||||
theme.defaultConfig.hashed = false;
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
Reflect.set(process.env, 'NODE_ENV', 'test');
|
||||
}
|
||||
|
||||
const alias = {
|
||||
// Downstream workspaces sometimes pnpm-override @lobechat/business-* packages to
|
||||
// internal implementations whose source files import alias paths that only exist
|
||||
|
||||
Reference in New Issue
Block a user