feat(grid): support deleting custom column formatters

This commit is contained in:
zipg authored and GitHub committed 2026-08-21 19:44:52 +08:00
1 parent 47e02b8da7
commit 8671a2fb2a
11 files changed
+508 -77

No files matched your search

+92 -23
View File
@@ -1076,8 +1076,13 @@ const formatterJsonPath = ref("$.user.name");
const formatterMaskPrefix = ref(4); const formatterMaskPrefix = ref(4);
const formatterMaskSuffix = ref(4); const formatterMaskSuffix = ref(4);
const formatterCustomId = ref(CUSTOM_FORMATTER_NEW); const formatterCustomId = ref(CUSTOM_FORMATTER_NEW);
const formatterCustomCapturedDeleteVersion = ref<number>();
const formatterCustomName = ref(""); const formatterCustomName = ref("");
const formatterCustomTemplate = ref("${value}"); const formatterCustomTemplate = ref("${value}");
const formatterCustomDeleteOpen = ref(false);
const formatterCustomDeleteLoading = ref(false);
const formatterCustomDeleteId = ref("");
const formatterCustomDeleteName = ref("");
const formatterForeignKeyRefSchema = ref(""); const formatterForeignKeyRefSchema = ref("");
const formatterForeignKeyRefTable = ref(""); const formatterForeignKeyRefTable = ref("");
const formatterForeignKeyRefColumn = ref(""); const formatterForeignKeyRefColumn = ref("");
@@ -1508,10 +1513,12 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
} else if (draft.kind === "custom-ref") { } else if (draft.kind === "custom-ref") {
const saved = settingsStore.editorSettings.customColumnFormatters[draft.formatterId]; const saved = settingsStore.editorSettings.customColumnFormatters[draft.formatterId];
formatterCustomId.value = saved ? saved.id : CUSTOM_FORMATTER_NEW; formatterCustomId.value = saved ? saved.id : CUSTOM_FORMATTER_NEW;
formatterCustomCapturedDeleteVersion.value = saved ? settingsStore.customColumnFormatterDeleteVersion(saved.id) : undefined;
formatterCustomName.value = saved?.name ?? ""; formatterCustomName.value = saved?.name ?? "";
formatterCustomTemplate.value = saved?.template ?? "${value}"; formatterCustomTemplate.value = saved?.template ?? "${value}";
} else if (draft.kind === "custom-template") { } else if (draft.kind === "custom-template") {
formatterCustomId.value = CUSTOM_FORMATTER_NEW; formatterCustomId.value = CUSTOM_FORMATTER_NEW;
formatterCustomCapturedDeleteVersion.value = undefined;
formatterCustomName.value = ""; formatterCustomName.value = "";
formatterCustomTemplate.value = draft.template; formatterCustomTemplate.value = draft.template;
} else if (draft.kind === "foreign-key-display") { } else if (draft.kind === "foreign-key-display") {
@@ -1585,22 +1592,33 @@ function handleColumnFormatterOpenChange(value: boolean, columnIndex: number) {
} }
} }
function saveColumnFormatter(columnIndex: number) { async function saveColumnFormatter(columnIndex: number) {
const column = props.result.columns[columnIndex]; const column = props.result.columns[columnIndex];
const key = column ? formatterKeyForColumn(column) : null; const key = column ? formatterKeyForColumn(column) : null;
if (!key) return; if (!key) return;
let formatter = currentFormatterDraft(); try {
if (formatterKind.value === "custom-template" && formatterCustomName.value.trim()) { let formatter = currentFormatterDraft();
const id = formatterCustomId.value === CUSTOM_FORMATTER_NEW ? createCustomFormatterId() : formatterCustomId.value; if (formatterKind.value === "custom-template" && formatterCustomName.value.trim()) {
const saved = settingsStore.upsertCustomColumnFormatter({ const id = formatterCustomId.value === CUSTOM_FORMATTER_NEW ? createCustomFormatterId() : formatterCustomId.value;
id, const saved = await settingsStore.upsertCustomColumnFormatter(
name: formatterCustomName.value, {
template: formatterCustomTemplate.value, id,
}); name: formatterCustomName.value,
if (saved) formatter = { kind: "custom-ref", formatterId: saved.id }; template: formatterCustomTemplate.value,
},
formatterCustomCapturedDeleteVersion.value,
);
if (!saved) {
selectCustomFormatter(CUSTOM_FORMATTER_NEW);
return;
}
formatter = { kind: "custom-ref", formatterId: saved.id };
}
settingsStore.updateColumnFormatter(key, formatter);
closeColumnFormatter();
} catch (error) {
toast(t("grid.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} }
settingsStore.updateColumnFormatter(key, formatter);
closeColumnFormatter();
} }
function clearColumnFormatter(columnIndex: number) { function clearColumnFormatter(columnIndex: number) {
@@ -1629,16 +1647,44 @@ function selectFormatterKind(value: FormatterDraftKind, columnIndex: number) {
function selectCustomFormatter(value: string) { function selectCustomFormatter(value: string) {
formatterCustomId.value = value; formatterCustomId.value = value;
if (value === CUSTOM_FORMATTER_NEW) { if (value === CUSTOM_FORMATTER_NEW) {
formatterCustomCapturedDeleteVersion.value = undefined;
formatterCustomName.value = ""; formatterCustomName.value = "";
formatterCustomTemplate.value = "${value}"; formatterCustomTemplate.value = "${value}";
return; return;
} }
const saved = settingsStore.editorSettings.customColumnFormatters[value]; const saved = settingsStore.editorSettings.customColumnFormatters[value];
if (!saved) return; if (!saved) return;
formatterCustomCapturedDeleteVersion.value = settingsStore.customColumnFormatterDeleteVersion(saved.id);
formatterCustomName.value = saved.name; formatterCustomName.value = saved.name;
formatterCustomTemplate.value = saved.template; formatterCustomTemplate.value = saved.template;
} }
function requestDeleteCustomFormatter() {
if (formatterCustomId.value === CUSTOM_FORMATTER_NEW) return;
const saved = settingsStore.editorSettings.customColumnFormatters[formatterCustomId.value];
if (!saved) return;
formatterCustomDeleteId.value = saved.id;
formatterCustomDeleteName.value = saved.name;
formatterCustomDeleteOpen.value = true;
}
async function confirmDeleteCustomFormatter() {
const id = formatterCustomDeleteId.value;
if (!id || formatterCustomDeleteLoading.value) return;
formatterCustomDeleteLoading.value = true;
try {
await settingsStore.deleteCustomColumnFormatter(id);
if (formatterCustomId.value === id) selectCustomFormatter(CUSTOM_FORMATTER_NEW);
formatterCustomDeleteOpen.value = false;
formatterCustomDeleteId.value = "";
formatterCustomDeleteName.value = "";
} catch (error) {
toast(t("grid.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
} finally {
formatterCustomDeleteLoading.value = false;
}
}
function createCustomFormatterId(): string { function createCustomFormatterId(): string {
return `fmt_${uuid()}`; return `fmt_${uuid()}`;
} }
@@ -11991,17 +12037,31 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<div class="text-xs font-medium text-muted-foreground"> <div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterSavedCustom") }} {{ t("grid.formatterSavedCustom") }}
</div> </div>
<Select :model-value="formatterCustomId" @update:model-value="(value: any) => selectCustomFormatter(String(value))"> <div class="flex items-center gap-1">
<SelectTrigger class="h-8 text-xs"> <Select :model-value="formatterCustomId" @update:model-value="(value: any) => selectCustomFormatter(String(value))">
<SelectValue /> <SelectTrigger class="h-8 min-w-0 flex-1 text-xs">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem :value="CUSTOM_FORMATTER_NEW">{{ t("grid.formatterNewCustom") }}</SelectItem> <SelectContent>
<SelectItem v-for="formatter in savedCustomFormatters" :key="formatter.id" :value="formatter.id"> <SelectItem :value="CUSTOM_FORMATTER_NEW">{{ t("grid.formatterNewCustom") }}</SelectItem>
{{ formatter.name }} <SelectItem v-for="formatter in savedCustomFormatters" :key="formatter.id" :value="formatter.id">
</SelectItem> {{ formatter.name }}
</SelectContent> </SelectItem>
</Select> </SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
:disabled="formatterCustomId === CUSTOM_FORMATTER_NEW"
:title="t('grid.formatterDeleteCustom')"
:aria-label="t('grid.formatterDeleteCustom')"
@click.stop="requestDeleteCustomFormatter"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
<label class="block space-y-1.5"> <label class="block space-y-1.5">
<span class="text-xs font-medium text-muted-foreground"> <span class="text-xs font-medium text-muted-foreground">
@@ -13140,6 +13200,15 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" /> <SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
</div> </div>
<DangerConfirmDialog
v-model:open="formatterCustomDeleteOpen"
:title="t('grid.formatterDeleteCustom')"
:message="t('grid.formatterDeleteCustomMessage', { name: formatterCustomDeleteName })"
:confirm-label="t('grid.formatterDeleteCustom')"
:loading="formatterCustomDeleteLoading"
:close-on-confirm="false"
@confirm="confirmDeleteCustomFormatter"
/>
<DangerConfirmDialog <DangerConfirmDialog
v-model:open="conditionalBulkEditConfirmOpen" v-model:open="conditionalBulkEditConfirmOpen"
:title="t('grid.conditionalBulkEditConfirmTitle')" :title="t('grid.conditionalBulkEditConfirmTitle')"
+2
View File
@@ -1728,6 +1728,8 @@ export default {
formatterMaskSuffix: "Visible suffix", formatterMaskSuffix: "Visible suffix",
formatterSavedCustom: "Saved templates", formatterSavedCustom: "Saved templates",
formatterNewCustom: "New template", formatterNewCustom: "New template",
formatterDeleteCustom: "Delete template",
formatterDeleteCustomMessage: 'Delete "{name}"? Formatting will be cleared from every column using this template.',
formatterCustomName: "Template name", formatterCustomName: "Template name",
formatterCustomNamePlaceholder: "Example: User label", formatterCustomNamePlaceholder: "Example: User label",
formatterCustomTemplateInput: "Template", formatterCustomTemplateInput: "Template",
+2
View File
@@ -1612,6 +1612,8 @@ export default withEnglishFallback({
formatterMaskSuffix: "Sufijo visible", formatterMaskSuffix: "Sufijo visible",
formatterSavedCustom: "Plantillas guardadas", formatterSavedCustom: "Plantillas guardadas",
formatterNewCustom: "Nueva plantilla", formatterNewCustom: "Nueva plantilla",
formatterDeleteCustom: "Eliminar plantilla",
formatterDeleteCustomMessage: '¿Eliminar "{name}"? Se quitará el formato de todas las columnas que usen esta plantilla.',
formatterCustomName: "Nombre de plantilla", formatterCustomName: "Nombre de plantilla",
formatterCustomNamePlaceholder: "Ejemplo: Etiqueta de usuario", formatterCustomNamePlaceholder: "Ejemplo: Etiqueta de usuario",
formatterCustomTemplateInput: "Plantilla", formatterCustomTemplateInput: "Plantilla",
+2
View File
@@ -1610,6 +1610,8 @@ export default withEnglishFallback({
formatterMaskSuffix: "Suffisso visibile", formatterMaskSuffix: "Suffisso visibile",
formatterSavedCustom: "Modelli salvati", formatterSavedCustom: "Modelli salvati",
formatterNewCustom: "Nuovo modello", formatterNewCustom: "Nuovo modello",
formatterDeleteCustom: "Elimina modello",
formatterDeleteCustomMessage: 'Eliminare "{name}"? La formattazione verrà rimossa da tutte le colonne che usano questo modello.',
formatterCustomName: "Nome modello", formatterCustomName: "Nome modello",
formatterCustomNamePlaceholder: "Esempio: Etichetta utente", formatterCustomNamePlaceholder: "Esempio: Etichetta utente",
formatterCustomTemplateInput: "Modello", formatterCustomTemplateInput: "Modello",
+2
View File
@@ -1628,6 +1628,8 @@ export default withEnglishFallback({
formatterMaskSuffix: "表示する接尾辞", formatterMaskSuffix: "表示する接尾辞",
formatterSavedCustom: "保存済みテンプレート", formatterSavedCustom: "保存済みテンプレート",
formatterNewCustom: "新しいテンプレート", formatterNewCustom: "新しいテンプレート",
formatterDeleteCustom: "テンプレートを削除",
formatterDeleteCustomMessage: "「{name}」を削除しますか?このテンプレートを使用しているすべての列から書式設定が解除されます。",
formatterCustomName: "テンプレート名", formatterCustomName: "テンプレート名",
formatterCustomNamePlaceholder: "例: ユーザーラベル", formatterCustomNamePlaceholder: "例: ユーザーラベル",
formatterCustomTemplateInput: "テンプレート", formatterCustomTemplateInput: "テンプレート",
+2
View File
@@ -1612,6 +1612,8 @@ export default withEnglishFallback({
formatterMaskSuffix: "Sufixo visível", formatterMaskSuffix: "Sufixo visível",
formatterSavedCustom: "Templates salvos", formatterSavedCustom: "Templates salvos",
formatterNewCustom: "Novo template", formatterNewCustom: "Novo template",
formatterDeleteCustom: "Excluir template",
formatterDeleteCustomMessage: 'Excluir "{name}"? A formatação será removida de todas as colunas que usam este template.',
formatterCustomName: "Nome do template", formatterCustomName: "Nome do template",
formatterCustomNamePlaceholder: "Exemplo: Rótulo do usuário", formatterCustomNamePlaceholder: "Exemplo: Rótulo do usuário",
formatterCustomTemplateInput: "Template", formatterCustomTemplateInput: "Template",
+2
View File
@@ -1653,6 +1653,8 @@ export default withEnglishFallback({
formatterMaskSuffix: "后缀保留", formatterMaskSuffix: "后缀保留",
formatterSavedCustom: "已保存模板", formatterSavedCustom: "已保存模板",
formatterNewCustom: "新建模板", formatterNewCustom: "新建模板",
formatterDeleteCustom: "删除模板",
formatterDeleteCustomMessage: "确定删除“{name}”吗?所有使用该模板的字段都会清除格式化设置。",
formatterCustomName: "模板名称", formatterCustomName: "模板名称",
formatterCustomNamePlaceholder: "例如:用户标签", formatterCustomNamePlaceholder: "例如:用户标签",
formatterCustomTemplateInput: "模板", formatterCustomTemplateInput: "模板",
+2
View File
@@ -1611,6 +1611,8 @@ export default withEnglishFallback({
formatterMaskSuffix: "字尾保留", formatterMaskSuffix: "字尾保留",
formatterSavedCustom: "已儲存範本", formatterSavedCustom: "已儲存範本",
formatterNewCustom: "建立範本", formatterNewCustom: "建立範本",
formatterDeleteCustom: "刪除範本",
formatterDeleteCustomMessage: "確定刪除「{name}」嗎?所有使用此範本的欄位都會清除格式設定。",
formatterCustomName: "範本名稱", formatterCustomName: "範本名稱",
formatterCustomNamePlaceholder: "例如:使用者標籤", formatterCustomNamePlaceholder: "例如:使用者標籤",
formatterCustomTemplateInput: "範本", formatterCustomTemplateInput: "範本",
+91
View File
@@ -805,6 +805,38 @@ describe("settingsStore editor settings persistence", () => {
setActivePinia(createPinia()); setActivePinia(createPinia());
}); });
it("initializes legacy settings before queueing formatter mutations", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue({
updateDownloadSource: "atomgit",
customColumnFormatters: {},
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
});
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
const formatter = { id: "fmt_pre_init", name: "Pre-init", template: "legacy:${value}" };
await store.upsertCustomColumnFormatter(formatter);
expect(loadEditorSettings).toHaveBeenCalledOnce();
expect(saveEditorSettings).toHaveBeenCalledTimes(2);
expect(saveEditorSettings.mock.calls[0][0]).toEqual(
expect.objectContaining({
updateDownloadSource: "cnb",
customColumnFormatters: {},
}),
);
expect(saveEditorSettings.mock.calls[1][0]).toEqual(
expect.objectContaining({
updateDownloadSource: "cnb",
customColumnFormatters: { fmt_pre_init: formatter },
}),
);
expect(store.editorSettings.customColumnFormatters.fmt_pre_init).toEqual(formatter);
});
it("rolls back a failed atomic update and allows retry", async () => { it("rolls back a failed atomic update and allows retry", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue({ const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "", ignoredUpdateVersion: "",
@@ -915,6 +947,65 @@ describe("settingsStore editor settings persistence", () => {
expect(store.editorSettings).toMatchObject({ ignoredUpdateVersion: "", theme: "xcode-dark" }); expect(store.editorSettings).toMatchObject({ ignoredUpdateVersion: "", theme: "xcode-dark" });
expect(saveEditorSettings.mock.calls[1][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "", theme: "xcode-dark" })); expect(saveEditorSettings.mock.calls[1][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "", theme: "xcode-dark" }));
}); });
it("serializes queued saves before a failed formatter delete", async () => {
let persistedSettings: Record<string, unknown> = {
customColumnFormatters: {
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
},
columnFormatters: {
first: { kind: "custom-ref", formatterId: "fmt_a" },
},
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
};
const loadEditorSettings = vi.fn(async () => JSON.parse(JSON.stringify(persistedSettings)));
const saveEditorSettings = vi.fn(async (settings: Record<string, unknown>) => {
persistedSettings = JSON.parse(JSON.stringify(settings));
});
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
saveEditorSettings.mockClear();
let resolveFirstSave!: () => void;
let saveCall = 0;
saveEditorSettings.mockImplementation(async (settings: Record<string, unknown>) => {
saveCall += 1;
if (saveCall === 1) {
await new Promise<void>((resolve) => {
resolveFirstSave = resolve;
});
}
if (saveCall === 3) throw new Error("delete save failed");
persistedSettings = JSON.parse(JSON.stringify(settings));
});
store.updateEditorSettings({ theme: "xcode-dark" });
await vi.waitFor(() => expect(saveEditorSettings).toHaveBeenCalledOnce());
store.updateEditorSettings({ resultRunDisplayMode: "list" });
const deletePromise = store.deleteCustomColumnFormatter("fmt_a");
const deleteRejected = expect(deletePromise).rejects.toThrow("delete save failed");
await Promise.resolve();
await Promise.resolve();
expect(store.editorSettings.customColumnFormatters.fmt_a).toBeDefined();
resolveFirstSave();
await deleteRejected;
expect(saveEditorSettings).toHaveBeenCalledTimes(3);
expect(store.editorSettings.customColumnFormatters.fmt_a).toBeDefined();
expect(store.editorSettings.columnFormatters.first).toEqual({ kind: "custom-ref", formatterId: "fmt_a" });
setActivePinia(createPinia());
const restartedStore = useSettingsStore();
await restartedStore.initEditorSettings();
expect(restartedStore.editorSettings.customColumnFormatters.fmt_a).toEqual({ id: "fmt_a", name: "A", template: "a:${value}" });
expect(restartedStore.editorSettings.columnFormatters.first).toEqual({ kind: "custom-ref", formatterId: "fmt_a" });
});
}); });
describe("settingsStore sidebar connection sort persistence", () => { describe("settingsStore sidebar connection sort persistence", () => {
+139 -54
View File
@@ -1304,23 +1304,34 @@ export const useSettingsStore = defineStore("settings", () => {
const isEditorSettingsLoaded = ref(false); const isEditorSettingsLoaded = ref(false);
let initEditorSettingsPromise: Promise<void> | null = null; let initEditorSettingsPromise: Promise<void> | null = null;
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = []; let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
let editorSettingsSaveQueue: Promise<void> | null = null; let editorSettingsOperationQueue: Promise<void> | null = null;
let editorSettingsAtomicUpdateQueue: Promise<void> = Promise.resolve();
let editorSettingsPatchRevision = 0; let editorSettingsPatchRevision = 0;
const editorSettingsFieldRevisions = new Map<keyof EditorSettings, number>(); const editorSettingsFieldRevisions = new Map<keyof EditorSettings, number>();
const customColumnFormatterDeleteVersions = new Map<string, number>();
let pendingAiChatSelection: AiChatSelectionState | null = null; let pendingAiChatSelection: AiChatSelectionState | null = null;
let aiChatSelectionSaveRunning = false; let aiChatSelectionSaveRunning = false;
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({})); const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
function enqueueEditorSettingsSave(): Promise<void> { function enqueueEditorSettingsOperation<T>(operation: () => Promise<T>): Promise<T> {
const saveCurrentSettings = () => api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value)); const queuedOperation = editorSettingsOperationQueue ? editorSettingsOperationQueue.then(operation) : operation();
const save = editorSettingsSaveQueue ? editorSettingsSaveQueue.catch(() => {}).then(saveCurrentSettings) : saveCurrentSettings(); const trackedOperation = queuedOperation.then(
const trackedSave = save.finally(() => { () => undefined,
if (editorSettingsSaveQueue === trackedSave) editorSettingsSaveQueue = null; () => undefined,
);
editorSettingsOperationQueue = trackedOperation;
void trackedOperation.finally(() => {
if (editorSettingsOperationQueue === trackedOperation) editorSettingsOperationQueue = null;
}); });
editorSettingsSaveQueue = trackedSave; return queuedOperation;
return trackedSave; }
function persistCurrentEditorSettings(): Promise<void> {
return api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
}
function enqueueEditorSettingsSave(): Promise<void> {
return enqueueEditorSettingsOperation(persistCurrentEditorSettings);
} }
function saveEditorSettings() { function saveEditorSettings() {
@@ -1823,32 +1834,33 @@ export const useSettingsStore = defineStore("settings", () => {
await enqueueEditorSettingsSave(); await enqueueEditorSettingsSave();
} }
async function enqueueEditorSettingsAtomicMutation<T>(mutation: () => Promise<T>): Promise<T> {
await initEditorSettings();
return enqueueEditorSettingsOperation(mutation);
}
function updateEditorSettingsAndPersist(partial: Partial<EditorSettings>): Promise<void> { function updateEditorSettingsAndPersist(partial: Partial<EditorSettings>): Promise<void> {
const update = editorSettingsAtomicUpdateQueue return enqueueEditorSettingsAtomicMutation(async () => {
.catch(() => {}) await initEditorSettings();
.then(async () => { const previous = editorSettingsSnapshot(editorSettings.value);
await initEditorSettings(); applyEditorSettingsPatch(partial);
const previous = editorSettingsSnapshot(editorSettings.value); const revision = markEditorSettingsPatch(partial);
applyEditorSettingsPatch(partial); try {
const revision = markEditorSettingsPatch(partial); await persistCurrentEditorSettings();
try { } catch (error) {
await enqueueEditorSettingsSave(); const restored = editorSettingsSnapshot(editorSettings.value) as unknown as Record<string, unknown>;
} catch (error) { const previousSettings = previous as unknown as Record<string, unknown>;
const restored = editorSettingsSnapshot(editorSettings.value) as unknown as Record<string, unknown>; let changed = false;
const previousSettings = previous as unknown as Record<string, unknown>; for (const key of Object.keys(partial) as (keyof EditorSettings)[]) {
let changed = false; if (partial[key] === undefined || editorSettingsFieldRevisions.get(key) !== revision) continue;
for (const key of Object.keys(partial) as (keyof EditorSettings)[]) { restored[key] = previousSettings[key];
if (partial[key] === undefined || editorSettingsFieldRevisions.get(key) !== revision) continue; editorSettingsFieldRevisions.set(key, ++editorSettingsPatchRevision);
restored[key] = previousSettings[key]; changed = true;
editorSettingsFieldRevisions.set(key, ++editorSettingsPatchRevision);
changed = true;
}
if (changed) editorSettings.value = normalizeEditorSettings(restored);
throw error;
} }
}); if (changed) editorSettings.value = normalizeEditorSettings(restored);
editorSettingsAtomicUpdateQueue = update.catch(() => {}); throw error;
return update; }
});
} }
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) { function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
@@ -1862,29 +1874,101 @@ export const useSettingsStore = defineStore("settings", () => {
updateEditorSettings({ columnFormatters }); updateEditorSettings({ columnFormatters });
} }
function upsertCustomColumnFormatter(formatter: CustomColumnFormatterConfig): CustomColumnFormatterConfig | undefined { function customColumnFormatterDeleteVersion(id: string): number {
const normalized = normalizeCustomColumnFormatter(formatter); return customColumnFormatterDeleteVersions.get(id) ?? 0;
if (!normalized) return undefined;
updateEditorSettings({
customColumnFormatters: {
...editorSettings.value.customColumnFormatters,
[normalized.id]: normalized,
},
});
return normalized;
} }
function deleteCustomColumnFormatter(id: string) { async function upsertCustomColumnFormatter(formatter: CustomColumnFormatterConfig, expectedDeleteVersion?: number): Promise<CustomColumnFormatterConfig | undefined> {
const customColumnFormatters = { const normalized = normalizeCustomColumnFormatter(formatter);
...editorSettings.value.customColumnFormatters, if (!normalized) return undefined;
}; return enqueueEditorSettingsAtomicMutation(async () => {
delete customColumnFormatters[id]; await initEditorSettings();
const columnFormatters = Object.fromEntries( if (expectedDeleteVersion !== undefined && customColumnFormatterDeleteVersion(normalized.id) !== expectedDeleteVersion) return undefined;
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => { const previous = editorSettings.value.customColumnFormatters[normalized.id];
return formatter.kind !== "custom-ref" || formatter.formatterId !== id; const partial = {
}), customColumnFormatters: {
); ...editorSettings.value.customColumnFormatters,
updateEditorSettings({ customColumnFormatters, columnFormatters }); [normalized.id]: normalized,
},
} satisfies Partial<EditorSettings>;
applyEditorSettingsPatch(partial);
markEditorSettingsPatch(partial);
try {
await persistCurrentEditorSettings();
} catch (error) {
const customColumnFormatters = {
...editorSettings.value.customColumnFormatters,
};
const current = customColumnFormatters[normalized.id];
if (current?.name === normalized.name && current.template === normalized.template) {
if (previous) {
customColumnFormatters[normalized.id] = previous;
} else {
delete customColumnFormatters[normalized.id];
}
const rollback = { customColumnFormatters } satisfies Partial<EditorSettings>;
applyEditorSettingsPatch(rollback);
markEditorSettingsPatch(rollback);
}
throw error;
}
return normalized;
});
}
async function deleteCustomColumnFormatter(id: string): Promise<void> {
return enqueueEditorSettingsAtomicMutation(async () => {
await initEditorSettings();
const previousDeleteVersion = customColumnFormatterDeleteVersion(id);
const deleteVersion = previousDeleteVersion + 1;
customColumnFormatterDeleteVersions.set(id, deleteVersion);
const previousFormatter = editorSettings.value.customColumnFormatters[id];
const customColumnFormatters = {
...editorSettings.value.customColumnFormatters,
};
delete customColumnFormatters[id];
const removedColumnFormatters = Object.fromEntries(
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => {
return formatter.kind === "custom-ref" && formatter.formatterId === id;
}),
);
const columnFormatters = Object.fromEntries(
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => {
return formatter.kind !== "custom-ref" || formatter.formatterId !== id;
}),
);
const partial = { customColumnFormatters, columnFormatters } satisfies Partial<EditorSettings>;
applyEditorSettingsPatch(partial);
markEditorSettingsPatch(partial);
try {
await persistCurrentEditorSettings();
} catch (error) {
if (customColumnFormatterDeleteVersion(id) === deleteVersion) {
if (previousDeleteVersion === 0) {
customColumnFormatterDeleteVersions.delete(id);
} else {
customColumnFormatterDeleteVersions.set(id, previousDeleteVersion);
}
}
const restoredCustomColumnFormatters = {
...editorSettings.value.customColumnFormatters,
};
if (previousFormatter && !restoredCustomColumnFormatters[id]) restoredCustomColumnFormatters[id] = previousFormatter;
const restoredColumnFormatters = {
...editorSettings.value.columnFormatters,
};
for (const [key, formatter] of Object.entries(removedColumnFormatters)) {
if (!restoredColumnFormatters[key]) restoredColumnFormatters[key] = formatter;
}
const rollback = {
customColumnFormatters: restoredCustomColumnFormatters,
columnFormatters: restoredColumnFormatters,
} satisfies Partial<EditorSettings>;
applyEditorSettingsPatch(rollback);
markEditorSettingsPatch(rollback);
throw error;
}
});
} }
return { return {
@@ -1921,6 +2005,7 @@ export const useSettingsStore = defineStore("settings", () => {
initMcpGlobalPolicy, initMcpGlobalPolicy,
updateMcpGlobalPolicy, updateMcpGlobalPolicy,
updateColumnFormatter, updateColumnFormatter,
customColumnFormatterDeleteVersion,
upsertCustomColumnFormatter, upsertCustomColumnFormatter,
deleteCustomColumnFormatter, deleteCustomColumnFormatter,
}; };
+172
View File
@@ -29,6 +29,16 @@ beforeEach(() => {
saveEditorSettingsMock.mockClear(); saveEditorSettingsMock.mockClear();
}); });
function createDeferred() {
let resolve!: () => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function withMockLocalStorage(initial: Record<string, string>, run: () => void | Promise<void>) { async function withMockLocalStorage(initial: Record<string, string>, run: () => void | Promise<void>) {
const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const values = new Map(Object.entries(initial)); const values = new Map(Object.entries(initial));
@@ -627,6 +637,168 @@ test("keeps only valid saved column formatter configs", () => {
}); });
}); });
test("deleting a saved custom formatter clears every reference to it", async () => {
await withMockLocalStorage({}, async () => {
setActivePinia(createPinia());
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({
customColumnFormatters: {
fmt_remove: { id: "fmt_remove", name: "Remove me", template: "remove:${value}" },
fmt_keep: { id: "fmt_keep", name: "Keep me", template: "keep:${value}" },
},
columnFormatters: {
first: { kind: "custom-ref", formatterId: "fmt_remove" },
second: { kind: "custom-ref", formatterId: "fmt_remove" },
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
mask: { kind: "mask", prefix: 1, suffix: 1 },
},
});
await store.deleteCustomColumnFormatter("fmt_remove");
assert.deepEqual(store.editorSettings.customColumnFormatters, {
fmt_keep: { id: "fmt_keep", name: "Keep me", template: "keep:${value}" },
});
assert.deepEqual(store.editorSettings.columnFormatters, {
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
mask: { kind: "mask", prefix: 1, suffix: 1 },
});
});
});
test("serializes concurrent custom formatter deletes", async () => {
await withMockLocalStorage({}, async () => {
setActivePinia(createPinia());
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({
customColumnFormatters: {
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
fmt_b: { id: "fmt_b", name: "B", template: "b:${value}" },
fmt_keep: { id: "fmt_keep", name: "Keep", template: "keep:${value}" },
},
columnFormatters: {
first: { kind: "custom-ref", formatterId: "fmt_a" },
second: { kind: "custom-ref", formatterId: "fmt_b" },
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
},
});
await store.persistEditorSettings();
await Promise.all([store.deleteCustomColumnFormatter("fmt_a"), store.deleteCustomColumnFormatter("fmt_b")]);
assert.deepEqual(store.editorSettings.customColumnFormatters, {
fmt_keep: { id: "fmt_keep", name: "Keep", template: "keep:${value}" },
});
assert.deepEqual(store.editorSettings.columnFormatters, {
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
});
const saved = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { customColumnFormatters?: Record<string, unknown> } | undefined;
assert.deepEqual(saved?.customColumnFormatters, {
fmt_keep: { id: "fmt_keep", name: "Keep", template: "keep:${value}" },
});
});
});
test("queues formatter upserts behind a deferred delete save", async () => {
await withMockLocalStorage({}, async () => {
setActivePinia(createPinia());
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({
customColumnFormatters: {
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
fmt_b: { id: "fmt_b", name: "B", template: "b:${value}" },
},
columnFormatters: {
first: { kind: "custom-ref", formatterId: "fmt_a" },
},
});
await store.persistEditorSettings();
const deleteSave = createDeferred();
saveEditorSettingsMock.mockImplementationOnce(() => deleteSave.promise);
const deletePromise = store.deleteCustomColumnFormatter("fmt_a");
await vi.waitFor(() => assert.equal(store.editorSettings.customColumnFormatters.fmt_a, undefined));
const upsertPromise = store.upsertCustomColumnFormatter({ id: "fmt_b", name: "B updated", template: "updated:${value}" });
await Promise.resolve();
assert.equal(store.editorSettings.customColumnFormatters.fmt_b.name, "B");
deleteSave.resolve();
await deletePromise;
await upsertPromise;
assert.deepEqual(store.editorSettings.customColumnFormatters, {
fmt_b: { id: "fmt_b", name: "B updated", template: "updated:${value}" },
});
assert.deepEqual(store.editorSettings.columnFormatters, {});
});
});
test("rolls back a failed delete before running a queued formatter upsert", async () => {
await withMockLocalStorage({}, async () => {
setActivePinia(createPinia());
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({
customColumnFormatters: {
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
},
columnFormatters: {
first: { kind: "custom-ref", formatterId: "fmt_a" },
},
});
await store.persistEditorSettings();
const deleteSave = createDeferred();
saveEditorSettingsMock.mockImplementationOnce(() => deleteSave.promise);
const deletePromise = store.deleteCustomColumnFormatter("fmt_a");
const deleteRejected = assert.rejects(deletePromise, /disk full/);
await vi.waitFor(() => assert.equal(store.editorSettings.customColumnFormatters.fmt_a, undefined));
const upsertPromise = store.upsertCustomColumnFormatter({ id: "fmt_b", name: "B", template: "b:${value}" });
await Promise.resolve();
assert.equal(store.editorSettings.customColumnFormatters.fmt_b, undefined);
deleteSave.reject(new Error("disk full"));
await deleteRejected;
await upsertPromise;
assert.deepEqual(store.editorSettings.customColumnFormatters, {
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
fmt_b: { id: "fmt_b", name: "B", template: "b:${value}" },
});
assert.deepEqual(store.editorSettings.columnFormatters, {
first: { kind: "custom-ref", formatterId: "fmt_a" },
});
});
});
test("rejects stale formatter drafts but allows deliberate same-id recreation", async () => {
await withMockLocalStorage({}, async () => {
setActivePinia(createPinia());
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({
customColumnFormatters: {
fmt_rebuild: { id: "fmt_rebuild", name: "Original", template: "original:${value}" },
},
});
await store.persistEditorSettings();
const capturedDeleteVersion = store.customColumnFormatterDeleteVersion("fmt_rebuild");
const staleFormatter = store.editorSettings.customColumnFormatters.fmt_rebuild;
await store.deleteCustomColumnFormatter("fmt_rebuild");
assert.equal(await store.upsertCustomColumnFormatter(staleFormatter, capturedDeleteVersion), undefined);
assert.equal(store.editorSettings.customColumnFormatters.fmt_rebuild, undefined);
const rebuilt = { id: "fmt_rebuild", name: "Rebuilt", template: "rebuilt:${value}" };
assert.deepEqual(await store.upsertCustomColumnFormatter(rebuilt), rebuilt);
assert.deepEqual(store.editorSettings.customColumnFormatters.fmt_rebuild, rebuilt);
assert.notEqual(store.customColumnFormatterDeleteVersion("fmt_rebuild"), capturedDeleteVersion);
});
});
test("AI provider presets include common hosted and local providers", () => { test("AI provider presets include common hosted and local providers", () => {
assert.equal(AI_PROVIDER_PRESETS.gemini.endpoint, "https://generativelanguage.googleapis.com"); assert.equal(AI_PROVIDER_PRESETS.gemini.endpoint, "https://generativelanguage.googleapis.com");
assert.equal(AI_PROVIDER_PRESETS.gemini.model, "gemini-1.5-pro"); assert.equal(AI_PROVIDER_PRESETS.gemini.model, "gemini-1.5-pro");