feat(structure): refresh the open table structure with F5

This commit is contained in:
kkwang.xx
2026-10-13 09:45:24 +08:00
committed by GitHub
parent 4eb9368cc7
commit 55019721a1
4 changed files with 134 additions and 3 deletions
@@ -5201,6 +5201,10 @@ function isShiftEnterShortcut(event: KeyboardEvent): boolean {
return !event.isComposing && event.key === "Enter" && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
}
function isPlainStructureRefreshShortcut(event: KeyboardEvent): boolean {
return !event.isComposing && !event.repeat && event.key === "F5" && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
}
function isPlainModDeleteShortcut(event: KeyboardEvent): boolean {
if (isPlainModShortcut(event, "delete")) return true;
if (!event.metaKey || event.ctrlKey || !isPlainModShortcut(event, "backspace")) return false;
@@ -5211,6 +5215,22 @@ function isPlainModDeleteShortcut(event: KeyboardEvent): boolean {
}
function onStructureEditorKeydown(event: KeyboardEvent) {
// Desktop blocks F5 before this bubble listener and marks it defaultPrevented.
// Handle the shortcut first so that guard still refreshes the open table (#11850).
if (isPlainStructureRefreshShortcut(event)) {
// Only react when the keypress originates inside this editor: the listener is
// window-level and every activated structure editor registers one, so without
// this check a plain F5 typed into another editor group (where F5 is the global
// refresh-data shortcut) would silently discard THIS editor's unsaved drafts.
const target = event.target;
if (!(target instanceof Element) || !rootRef.value?.contains(target)) return;
if (target.closest('[role="dialog"], [role="alertdialog"]')) return;
if (isCreateMode.value) return;
event.preventDefault();
event.stopPropagation();
if (!loading.value && !saving.value && !ddlLoading.value) void reloadStructureFromDatabase();
return;
}
if (event.defaultPrevented) return;
if (activeTab.value === "columns" && !eventTargetUsesNativeClipboard(event)) {
if (isPlainClipboardShortcut(event, "a")) {
@@ -5629,7 +5649,7 @@ watch(
<Database :class="[structureIconClass, 'text-muted-foreground']" />
<span class="min-w-0 flex-1 truncate font-medium">{{ targetLabel || t("editor.noDatabase") }}</span>
<Badge variant="outline">{{ connection?.driver_label || databaseType }}</Badge>
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving || ddlLoading" @click="reloadStructureFromDatabase">
<Button v-if="!isCreateMode" variant="ghost" size="sm" :class="structureToolbarButtonClass" :disabled="loading || saving || ddlLoading" :title="t('structureEditor.refresh') + ' (F5)'" aria-keyshortcuts="F5" data-refresh-structure-shortcut @click="reloadStructureFromDatabase">
<RefreshCw :class="structureIconClass" />
{{ t("structureEditor.refresh") }}
</Button>
@@ -388,4 +388,115 @@ describe("TableStructureEditor DDL tab lifecycle", () => {
expect(commentCalls).toHaveLength(2);
expect(commentCalls.at(-1)?.[3]).toEqual({ force: true });
});
it("refreshes the open table on F5 after the desktop reload guard", async () => {
let tableComment = "Old comment";
mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({
value: facet === "columns" ? [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true }] : facet === "comment" ? tableComment : facet === "owner" ? "app_user" : [],
cacheStatus: "remote",
}));
const pendingDdl = deferred<{ ddl: string; cacheStatus: "remote" }>();
mocks.loadObjectDdl.mockImplementationOnce(() => pendingDdl.promise);
const root = document.createElement("div");
document.body.append(root);
app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
schema: "public",
tableName: "users",
initialTab: "ddl",
});
app.mount(root);
const refreshButton = () => buttonWithText(root, "structureEditor.refresh");
await vi.waitFor(() => expect(refreshButton().disabled).toBe(true));
// F5 originates inside the editor (bubbles up to the window-level listener).
refreshButton().dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1);
expect(mocks.invalidateObjectDdl).not.toHaveBeenCalled();
pendingDdl.resolve({ ddl: DDL, cacheStatus: "remote" });
const commentInput = () => root.querySelector<HTMLInputElement>('input[placeholder="structureEditor.tableCommentPlaceholder"]');
await vi.waitFor(() => expect(refreshButton().disabled).toBe(false));
await vi.waitFor(() => expect(commentInput()?.value).toBe("Old comment"));
expect(refreshButton().getAttribute("aria-keyshortcuts")).toBe("F5");
expect(refreshButton().getAttribute("title")).toBe("structureEditor.refresh (F5)");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
const dialogInput = document.createElement("input");
dialog.append(dialogInput);
document.body.append(dialog);
dialogInput.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
await nextTick();
expect(mocks.invalidateObjectDdl).not.toHaveBeenCalled();
dialog.remove();
const blockBrowserReload = (event: KeyboardEvent) => {
if (event.key === "F5") event.preventDefault();
};
window.addEventListener("keydown", blockBrowserReload, true);
try {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true, ctrlKey: true }));
await nextTick();
expect(mocks.invalidateObjectDdl).not.toHaveBeenCalled();
tableComment = "Updated comment";
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint, name text);", cacheStatus: "remote" });
refreshButton().dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(commentInput()?.value).toBe("Updated comment"));
expect(mocks.invalidateObjectDdl).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener("keydown", blockBrowserReload, true);
}
});
it("ignores F5 dispatched from outside the editor root", async () => {
// The keydown listener is window-level and shared by every activated structure
// editor; an F5 typed in another editor group (e.g. the global refresh-data
// shortcut) must not discard this editor's drafts.
mocks.loadObjectDdl.mockResolvedValue({ ddl: DDL, cacheStatus: "remote" });
const root = document.createElement("div");
document.body.append(root);
app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
schema: "public",
tableName: "users",
initialTab: "ddl",
});
app.mount(root);
await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1));
const outside = document.createElement("input");
document.body.append(outside);
try {
outside.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
await nextTick();
expect(mocks.invalidateObjectDdl).not.toHaveBeenCalled();
expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1);
} finally {
outside.remove();
}
});
it("leaves a create-table draft alone on F5", async () => {
const root = document.createElement("div");
document.body.append(root);
app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
schema: "public",
tableName: "",
});
app.mount(root);
await nextTick();
expect(root.textContent).not.toContain("structureEditor.refresh");
window.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
await nextTick();
expect(mocks.invalidateObjectDdl).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -120,7 +120,7 @@ description: 可视化查看和修改字段、索引、外键与触发器,并
- 编辑草稿基于打开时读取的元数据,不会自动合并其他客户端的结构变更
- 执行失败后,数据库可能已经成功执行前面的部分语句
- SQLite 重建和多语句 DDL 的原子性取决于数据库和驱动
- 刷新会重新读取结构,未保存草稿可能需要先确认放弃
- F5 或工具栏「刷新结构」会重新读取结构,未保存草稿可能需要先确认放弃
- 只读连接会阻止保存;生产连接每次写入都需要明确确认
## 使用建议
+1 -1
View File
@@ -120,7 +120,7 @@ Do not assume that every DDL feature is editable because a database can connect.
- Drafts are based on metadata read when the editor opened and do not merge external schema changes automatically
- If execution fails, earlier statements may already have succeeded
- Atomicity of SQLite rebuilds and multi-statement DDL depends on database and driver behavior
- Refresh reloads structure and may require discarding an unsaved draft
- F5 or the toolbar Refresh Structure action reloads structure and may require discarding an unsaved draft
- Read-only connections block save, and production writes require explicit confirmation every time
## Recommendations