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
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2162,6 +2162,7 @@
"Retry": "Retry",
"ResizeDatabaseSidebar": "Resize database sidebar",
"CloseDatabaseExplorer": "Close database explorer",
"RefreshResourceTree": "Refresh resource tree",
"WaitingConnection": "Waiting for connection details…",
"AuthenticatingSession": "Authenticating your database session…",
"ConnectingDatabaseService": "Connecting to the database service…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "Reintentar",
"ResizeDatabaseSidebar": "Cambiar el tamaño de la barra lateral de la base de datos",
"CloseDatabaseExplorer": "Cerrar el explorador de base de datos",
"RefreshResourceTree": "Actualizar árbol de recursos",
"WaitingConnection": "Esperando los detalles de conexión…",
"AuthenticatingSession": "Autenticando su sesión de base de datos…",
"ConnectingDatabaseService": "Conectando al servicio de base de datos…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "再試行",
"ResizeDatabaseSidebar": "データベースサイドバーのサイズ変更",
"CloseDatabaseExplorer": "データベースエクスプローラーを閉じる",
"RefreshResourceTree": "リソースツリーを更新",
"WaitingConnection": "接続情報を待っています…",
"AuthenticatingSession": "データベースセッションを認証しています…",
"ConnectingDatabaseService": "データベースサービスに接続しています…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "다시 시도",
"ResizeDatabaseSidebar": "데이터베이스 사이드바 크기 조정",
"CloseDatabaseExplorer": "데이터베이스 탐색기 닫기",
"RefreshResourceTree": "리소스 트리 새로 고침",
"WaitingConnection": "연결 정보를 기다리는 중…",
"AuthenticatingSession": "데이터베이스 세션을 인증하는 중…",
"ConnectingDatabaseService": "데이터베이스 서비스에 연결하는 중…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/pt_br.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "Tentar novamente",
"ResizeDatabaseSidebar": "Redimensionar a barra lateral do banco de dados",
"CloseDatabaseExplorer": "Fechar o explorador do banco de dados",
"RefreshResourceTree": "Atualizar árvore de recursos",
"WaitingConnection": "Aguardando detalhes da conexão…",
"AuthenticatingSession": "Autenticando sua sessão do banco de dados…",
"ConnectingDatabaseService": "Conectando ao serviço do banco de dados…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "Повторить",
"ResizeDatabaseSidebar": "Изменить размер боковой панели базы данных",
"CloseDatabaseExplorer": "Закрыть проводник базы данных",
"RefreshResourceTree": "Обновить дерево ресурсов",
"WaitingConnection": "Ожидание данных подключения…",
"AuthenticatingSession": "Аутентификация сеанса базы данных…",
"ConnectingDatabaseService": "Подключение к службе базы данных…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "Thử lại",
"ResizeDatabaseSidebar": "Điều chỉnh kích thước thanh bên cơ sở dữ liệu",
"CloseDatabaseExplorer": "Đóng trình duyệt cơ sở dữ liệu",
"RefreshResourceTree": "Làm mới cây tài nguyên",
"WaitingConnection": "Đang chờ thông tin kết nối…",
"AuthenticatingSession": "Đang xác thực phiên cơ sở dữ liệu…",
"ConnectingDatabaseService": "Đang kết nối đến dịch vụ cơ sở dữ liệu…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2156,6 +2156,7 @@
"Retry": "重试",
"ResizeDatabaseSidebar": "调整数据库侧栏宽度",
"CloseDatabaseExplorer": "关闭数据库资源管理",
"RefreshResourceTree": "刷新资源树",
"WaitingConnection": "等待连接信息…",
"AuthenticatingSession": "正在验证数据库会话…",
"ConnectingDatabaseService": "正在连接数据库服务…",
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/zh_hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -1989,6 +1989,7 @@
"Retry": "重試",
"ResizeDatabaseSidebar": "調整資料庫側欄寬度",
"CloseDatabaseExplorer": "關閉資料庫資源管理",
"RefreshResourceTree": "重新整理資源樹",
"WaitingConnection": "等待連線資訊…",
"AuthenticatingSession": "正在驗證資料庫工作階段…",
"ConnectingDatabaseService": "正在連線資料庫服務…",
Expand Down
3 changes: 2 additions & 1 deletion ui/chen/components/DataViewImportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ const mapped = computed(() => {
if (!parsed.value) return { rows: [] as Array<Record<string, string | null>>, error: "" };
try {
return { rows: mapChenCsvRows(parsed.value, props.fields, emptyValue.value), error: "" };
} catch {
} catch (cause) {
console.warn("[chen] CSV column mapping failed:", cause instanceof Error ? cause.message : cause);
return { rows: [], error: t("Chen.MapCsvColumnsFailed") };
}
});
Expand Down
2 changes: 1 addition & 1 deletion ui/chen/components/DataViewPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ function importCsvRows(rows: Array<Record<string, string | null>>) {
<DataViewImportDialog
v-if="importDialogOpen"
v-model:open="importDialogOpen"
:fields="insertableFields"
:fields="tab.data?.fields || []"
@confirm="importCsvRows"
/>

Expand Down
4 changes: 2 additions & 2 deletions ui/chen/components/ResourceTreePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ const sqlKeywordCaseModel = computed({
:aria-label="t('Chen.CloseDatabaseExplorer')"
@click="emit('close')"
/>
<UTooltip :text="t('Common.Refresh')" :delay-duration="150">
<UTooltip :text="t('Chen.RefreshResourceTree')" :delay-duration="150">
<button
type="button"
class="grid size-6 shrink-0 place-items-center rounded-lg text-muted transition-colors hover:bg-[var(--app-hover-strong)] hover:text-highlighted"
:aria-label="t('Common.Refresh')"
:aria-label="t('Chen.RefreshResourceTree')"
@click="emit('refresh')"
>
<UIcon name="i-lucide-refresh-cw" class="size-3.5" />
Expand Down
187 changes: 187 additions & 0 deletions ui/chen/composables/useChenResourceTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type { ChenTreeNode } from "~/chen/types";

import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useChenResourceTree } from "~/chen/composables/useChenResourceTree";

const { fetchChenTreeChildren } = vi.hoisted(() => ({
fetchChenTreeChildren: vi.fn()
}));

vi.mock("~/chen/api", () => ({
fetchChenTreeChildren,
runChenAction: vi.fn()
}));

const DATASOURCE = "datasource:db";
const DATABASE = "datasource:db,database:app";
const SCHEMA = "datasource:db,database:app,schema:public";
const TABLES = "datasource:db,database:app,schema:public,tables";
const VIEWS = "datasource:db,database:app,schema:public,views";
const USERS = "datasource:db,database:app,schema:public,table:users";
const ORDERS = "datasource:db,database:app,schema:public,table:orders";
const RECENT_TABLES = "__chen_recent_tables__";

function node(key: string, type: string, extra: Partial<ChenTreeNode> = {}): ChenTreeNode {
return {
key,
type,
label: key,
hasChildren: type !== "table" && type !== "view",
...extra
};
}

describe("useChenResourceTree refreshRoot", () => {
let catalog: Record<string, ChenTreeNode[]>;

beforeEach(() => {
catalog = {
__root__: [node(DATASOURCE, "datasource")],
[DATASOURCE]: [node(DATABASE, "database")],
[DATABASE]: [node(SCHEMA, "schema")],
[SCHEMA]: [node(TABLES, "tables"), node(VIEWS, "views")],
[TABLES]: [node(USERS, "table")],
[VIEWS]: []
};

fetchChenTreeChildren.mockReset();
fetchChenTreeChildren.mockImplementation(async (_token: string, parent?: ChenTreeNode | null) => {
const key = parent?.key || "__root__";
return (catalog[key] || []).map((item) => ({ ...item }));
});
});

function createTree() {
return useChenResourceTree(ref("token"));
}

function fetchCalls() {
return fetchChenTreeChildren.mock.calls.map(([, parent, force]) => ({
parent: (parent as ChenTreeNode | null | undefined)?.key ?? null,
force: Boolean(force)
}));
}

async function expandKeys(tree: ReturnType<typeof useChenResourceTree>, keys: string[]) {
await tree.loadNodeChildren(null);
tree.expandedKeys.value = [];
for (const key of keys) {
const current = tree.findNodeByKey(key);
expect(current, key).toBeTruthy();
tree.expandedKeys.value = [...tree.expandedKeys.value, key];
await tree.loadNodeChildren(current);
}
}

it("reloads the whole expanded path and keeps those nodes expanded", async () => {
const tree = createTree();
await expandKeys(tree, [DATASOURCE, DATABASE, SCHEMA, TABLES]);
tree.expandedKeys.value = [...tree.expandedKeys.value, RECENT_TABLES];
catalog[TABLES] = [node(USERS, "table"), node(ORDERS, "table")];
fetchChenTreeChildren.mockClear();

await tree.refreshRoot();

expect(fetchCalls()).toEqual([
{ parent: null, force: true },
{ parent: DATASOURCE, force: true },
{ parent: DATABASE, force: true },
{ parent: SCHEMA, force: true },
{ parent: TABLES, force: true }
]);
expect(tree.expandedKeys.value).toEqual([DATASOURCE, DATABASE, SCHEMA, TABLES, RECENT_TABLES]);
expect(tree.findNodeByKey(TABLES)?.children?.map((item) => item.key)).toEqual([USERS, ORDERS]);
expect(tree.findNodeByKey(VIEWS)?.children).toBeUndefined();
});

it("drops expanded keys that no longer exist instead of leaving empty expanded folders", async () => {
const tree = createTree();
await expandKeys(tree, [DATASOURCE, DATABASE, SCHEMA, TABLES]);
catalog[SCHEMA] = [node(VIEWS, "views")];
delete catalog[TABLES];

await tree.refreshRoot();

expect(tree.expandedKeys.value).toEqual([DATASOURCE, DATABASE, SCHEMA]);
expect(tree.findNodeByKey(TABLES)).toBeNull();
expect(tree.findNodeByKey(SCHEMA)?.children?.map((item) => item.key)).toEqual([VIEWS]);
});

it("collapses a node when its children fail to reload", async () => {
const tree = createTree();
await expandKeys(tree, [DATASOURCE, DATABASE, SCHEMA]);
fetchChenTreeChildren.mockImplementation(async (_token: string, parent?: ChenTreeNode | null, force?: boolean) => {
const key = parent?.key || "__root__";
if (key === SCHEMA && force) throw new Error("schema gone");
return (catalog[key] || []).map((item) => ({ ...item }));
});

await tree.refreshRoot();

expect(tree.expandedKeys.value).toEqual([DATASOURCE, DATABASE]);
expect(tree.findNodeByKey(SCHEMA)?.children).toBeUndefined();
expect(tree.loadErrors[SCHEMA]).toBe("schema gone");
});

it("does not reconnect or clear expansion when the root reload fails", async () => {
const tree = createTree();
await expandKeys(tree, [DATASOURCE, DATABASE]);
const expandedBefore = [...tree.expandedKeys.value];
const rootBefore = tree.rootNodes.value[0];
fetchChenTreeChildren.mockImplementation(async (_token: string, parent?: ChenTreeNode | null) => {
if (!parent) throw new Error("root unavailable");
const key = parent.key || "__root__";
return (catalog[key] || []).map((item) => ({ ...item }));
});

await expect(tree.refreshRoot()).rejects.toThrow("root unavailable");
expect(tree.expandedKeys.value).toEqual(expandedBefore);
expect(tree.rootNodes.value[0]).toBe(rootBefore);
});

it("does not leave an in-flight folder expanded without children after whole-tree refresh", async () => {
const tree = createTree();
await expandKeys(tree, [DATASOURCE, DATABASE, SCHEMA]);

let releaseStale!: (value: ChenTreeNode[]) => void;
const staleLoad = new Promise<ChenTreeNode[]>((resolve) => {
releaseStale = resolve;
});
fetchChenTreeChildren.mockImplementation(async (_token: string, parent?: ChenTreeNode | null, force?: boolean) => {
const key = parent?.key || "__root__";
if (key === SCHEMA && !force) return staleLoad;
return (catalog[key] || []).map((item) => ({ ...item }));
});

const staleNode = tree.findNodeByKey(SCHEMA);
const inFlight = tree.loadNodeChildren(staleNode);
expect(tree.loadingChildren[SCHEMA]).toBe(true);
catalog[SCHEMA] = [node(TABLES, "tables")];

await tree.refreshRoot();
releaseStale([node(VIEWS, "views")]);
await inFlight;

const schema = tree.findNodeByKey(SCHEMA);
expect(Boolean(tree.expandedKeys.value.includes(SCHEMA) && !Array.isArray(schema?.children))).toBe(false);
expect(schema?.children?.map((item) => item.key)).toEqual([TABLES]);
expect(tree.childrenMap[SCHEMA]?.map((item) => item.key)).toEqual([TABLES]);
});

it("keeps node-level refresh on the clicked node only", async () => {
const tree = createTree();
await expandKeys(tree, [DATASOURCE, DATABASE, SCHEMA, TABLES]);
const expandedBefore = [...tree.expandedKeys.value];
const tables = tree.findNodeByKey(TABLES);
catalog[TABLES] = [node(ORDERS, "table")];
fetchChenTreeChildren.mockClear();

await tree.loadNodeChildren(tables, true);

expect(fetchCalls()).toEqual([{ parent: TABLES, force: true }]);
expect(tree.expandedKeys.value).toEqual(expandedBefore);
expect(tables?.children?.map((item) => item.key)).toEqual([ORDERS]);
expect(tree.findNodeByKey(USERS)).toBeNull();
});
});
57 changes: 55 additions & 2 deletions ui/chen/composables/useChenResourceTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function useChenResourceTree(chenToken: Ref<string>, options: UseChenReso
const childrenMap = reactive<Record<string, ChenTreeNode[]>>({});
const loadingChildren = reactive<Record<string, boolean>>({});
const loadErrors = reactive<Record<string, string>>({});
const loadGenerations = new Map<string, number>();
const expandedKeys = ref<string[]>([]);
const selectedNodeKey = ref("");

Expand Down Expand Up @@ -74,14 +75,17 @@ export function useChenResourceTree(chenToken: Ref<string>, options: UseChenReso

async function loadNodeChildren(node?: ChenTreeNode | null, force = false) {
const key = node?.key || "__root__";
if (loadingChildren[key]) return;
if (loadingChildren[key] && !force) return;
const generation = (loadGenerations.get(key) || 0) + 1;
loadGenerations.set(key, generation);
loadingChildren[key] = true;
loadErrors[key] = "";

try {
const items = normalizeTreeNodes(
await fetchChenTreeChildren(chenToken.value, node, force, options.endpointUrl?.value)
);
if (loadGenerations.get(key) !== generation) return;
if (!node) {
rootNodes.value = items;
return;
Expand All @@ -90,18 +94,67 @@ export function useChenResourceTree(chenToken: Ref<string>, options: UseChenReso
childrenMap[node.key] = items;
node.children = items;
} catch (cause) {
if (loadGenerations.get(key) !== generation) return;
loadErrors[key] = normalizeErrorMessage(cause);
options.onLoadError?.(node ?? null, cause);
// Root failures must propagate so the session can surface a fatal error
// state; per-node failures degrade to an empty subtree + a toast.
if (!node) throw cause;
} finally {
loadingChildren[key] = false;
if (loadGenerations.get(key) === generation) loadingChildren[key] = false;
}
}

function collectNodeKeys(nodes: ChenTreeNode[], keys = new Set<string>()) {
for (const node of nodes) {
keys.add(node.key);
if (node.children?.length) collectNodeKeys(node.children, keys);
}
return keys;
}

function clearChildCaches() {
for (const key of Object.keys(childrenMap)) delete childrenMap[key];
for (const key of Object.keys(loadErrors)) {
if (key !== "__root__") delete loadErrors[key];
}
}

async function restoreExpandedNodes(wanted: Set<string>) {
const restored: string[] = [];

async function visit(nodes: ChenTreeNode[]) {
for (const node of nodes) {
if (!wanted.has(node.key)) continue;
if (node.leaf || node.hasChildren === false) {
restored.push(node.key);
continue;
}

await loadNodeChildren(node, true);
if (loadErrors[node.key] || !Array.isArray(node.children)) continue;
restored.push(node.key);
if (node.children.length) await visit(node.children);
}
}

await visit(rootNodes.value);
return restored;
}

async function refreshRoot() {
const previousExpandedKeys = [...expandedKeys.value];
const previousTreeKeys = collectNodeKeys(rootNodes.value);
await loadNodeChildren(null, true);
clearChildCaches();

// Drop resource-tree expansion before restoring paths so a newly fetched
// root cannot render as expanded-without-children.
const preservedKeys = previousExpandedKeys.filter((key) => !previousTreeKeys.has(key));
expandedKeys.value = preservedKeys;
const restoredKeys = await restoreExpandedNodes(new Set(previousExpandedKeys));
const kept = new Set([...preservedKeys, ...restoredKeys]);
expandedKeys.value = previousExpandedKeys.filter((key) => kept.has(key));
}

async function expandInitialTree() {
Expand Down
Loading
Loading